Skip to main content

max / makenotwork

Publish the Alloy hotfix RPM repo through a server-minted presigned PUT An admin asks POST /api/v1/admin/rpm/uploads for a presigned PutObject against a dedicated makenotwork-rpm bucket, then PUTs the bytes straight to object storage. The publisher holds no S3 credential, which is the custody argument that chose this shape over putting a write key on a laptop (alloy 32423bdd, ruled (c)). Serving is a new Caddy site block modelled on the cdn one, not a widening of it: that block rewrites into makenotwork-public by path and its comment is emphatic about why that bucket must never be the main one. dnf and rpm-ostree fetch repomd.xml and packages by path with no auth, so a published fix reaches machines with no deploy at all. The deploy this costs is one-time, for the code. Auth is the SyncKit bearer token the ota publish path already mints, narrowed to the configured admin: SyncUser::user_id is the same UserId require_admin checks, so the gate is the identical value and mnw-cli reuses the OAuth flow it already drives. A non-admin gets 404, and an unset ADMIN_USER_ID refuses everyone. S3Client::generate_rpm_key is the one generator whose whole job is refusing input, because a yum repository IS a path layout createrepo_c writes and the server cannot invent it. Traversal, absolute paths, empty segments and non-repository extensions are all unrepresentable rather than merely unlikely. Which layout the repo uses stays 86cb87b9's business. mnw-cli rpm publish mirrors a createrepo_c directory into the bucket, packages first and repomd.xml last, so an interrupted publish leaves unreferenced objects rather than an index promising packages that are not there. Cache-Control splits on the same line: packages are immutable, repomd.xml must never be held at an edge. The bucket, DNS, Cloudflare proxy and write credential are still Max's to provision (alloy 23f599d9); the code cannot be verified end to end until they exist.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 17:25 UTC
Signed with PGP, not checked
Commit: d1a5480c0cd65dea949829d1b18c668d6950db3b
Parent: 1833e1a
16 files changed, +1281 insertions, -3 deletions
@@ -17,6 +17,7 @@
17 17 mod format;
18 18 mod ota;
19 19 mod rate_limit;
20 + mod rpm;
20 21 mod ssh;
21 22 mod staging;
22 23 mod tls;
@@ -60,6 +61,11 @@
60 61 if argv.get(1).map(String::as_str) == Some("ota") {
61 62 return ota::run(&argv[2..]).await;
62 63 }
64 + // Same shape as `ota` above: an operator one-shot, routed off argv before
65 + // the SSH daemon starts.
66 + if argv.get(1).map(String::as_str) == Some("rpm") {
67 + return rpm::run(&argv[2..]).await;
68 + }
63 69
64 70 tracing_subscriber::fmt()
65 71 .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?))
@@ -336,7 +336,7 @@
336 336 /// browser to the authorize URL, capture the returned code, and exchange it for
337 337 /// a session token. The browser must run on the same machine as this command
338 338 /// (the redirect targets `http://127.0.0.1:<port>/`).
339 - async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> {
339 + pub(crate) async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> {
340 340 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
341 341 .await
342 342 .context("failed to bind a localhost listener for the OAuth redirect")?;
@@ -176,6 +176,49 @@
176 176 }
177 177 }
178 178
179 + # Alloy hotfix RPM repository: reverse-proxies to Hetzner Object Storage.
180 + # Modelled on the cdn block above and deliberately NOT folded into it: that
181 + # block rewrites into makenotwork-public by path, and its comment is emphatic
182 + # about why that bucket must never be the main one. A separate block for a
183 + # separate bucket keeps that argument intact.
184 + #
185 + # What fetches this is dnf and rpm-ostree, by path, with no auth: repomd.xml,
186 + # then the metadata and packages it names. Nothing the server proper offers is
187 + # used, which is the point — a published hotfix reaches machines with no deploy
188 + # at all. Writes never come through here; they are presigned PUTs straight to
189 + # the bucket, minted by /api/v1/admin/rpm/uploads.
190 + #
191 + # Requires: public-read s3:GetObject policy on makenotwork-rpm, a Cloudflare
192 + # DNS A record for rpm.makenot.work (proxy ON), and the server configured with
193 + # S3_RPM_BUCKET (or RPM_S3_*) plus RPM_BASE_URL=https://rpm.makenot.work.
194 + rpm.makenot.work {
195 + import cloudflare_tls
196 +
197 + # Only allow GET (fetches). Block mutations — publishing does not come
198 + # through Caddy at all.
199 + @not_get not method GET HEAD
200 + respond @not_get 405
201 +
202 + rewrite * /makenotwork-rpm{uri}
203 + reverse_proxy https://fsn1.your-objectstorage.com {
204 + header_up Host fsn1.your-objectstorage.com
205 + }
206 +
207 + header {
208 + X-Content-Type-Options "nosniff"
209 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
210 + # Cache-Control is set on the objects themselves at presign time, and
211 + # the split is load-bearing: packages are immutable and cache forever,
212 + # repomd.xml must not be cached or a published fix never arrives.
213 + # See routes::rpm::cache_control_for.
214 + }
215 +
216 + log {
217 + output file /var/log/caddy/rpm.log
218 + format json
219 + }
220 + }
221 +
179 222 # dl.maxj.phd download host retired 2026-06-09. MNW now serves all downloads
180 223 # (creator product pages / makenot.work DMGs). The maxjphd_tls mTLS snippet and
181 224 # the dl.maxj.phd file_server block were removed with it; the /etc/caddy/maxj-phd-origin*
@@ -1042,6 +1042,8 @@
1042 1042 storage: None,
1043 1043 synckit_storage: None,
1044 1044 public_storage: None,
1045 + rpm_storage: None,
1046 + rpm_base_url: None,
1045 1047 stripe: None,
1046 1048 admin_user_id: Some(user.id),
1047 1049 synckit_jwt_secret: None,
@@ -1127,6 +1129,8 @@
1127 1129 storage: None,
1128 1130 synckit_storage: None,
1129 1131 public_storage: None,
1132 + rpm_storage: None,
1133 + rpm_base_url: None,
1130 1134 stripe: None,
1131 1135 admin_user_id: None,
1132 1136 synckit_jwt_secret: None,
@@ -28,6 +28,22 @@
28 28 /// overridden by `S3_PUBLIC_BUCKET`. Required in production (the CDN serves
29 29 /// ONLY this bucket); `None` in dev when `S3_PUBLIC_BUCKET` is unset.
30 30 pub public_storage: Option<StorageConfig>,
31 + /// Bucket holding the Alloy hotfix RPM repository: the `.rpm` files and the
32 + /// `createrepo_c` metadata that `dnf`/`rpm-ostree` fetch by path. Served
33 + /// beside the server by a GET/HEAD-only Caddy block, exactly as the CDN
34 + /// bucket is, so a published fix reaches machines with no deploy at all.
35 + ///
36 + /// Resolved from `RPM_S3_*` when a dedicated credential is provisioned, and
37 + /// otherwise from the main storage with the bucket overridden by
38 + /// `S3_RPM_BUCKET`. `None` when neither is set, which is every dev
39 + /// environment; the publish endpoint then answers 503 rather than 404, so
40 + /// "not configured here" never reads as "the route is gone".
41 + pub rpm_storage: Option<StorageConfig>,
42 + /// Public render base for [`Self::rpm_storage`] (e.g.
43 + /// `https://rpm.makenot.work`), the host the Caddy block answers on. Only
44 + /// used to tell an operator where a published object landed; nothing
45 + /// durable is written from it. `None` when `RPM_BASE_URL` is unset.
46 + pub rpm_base_url: Option<String>,
31 47 /// Stripe payment configuration (optional)
32 48 pub stripe: Option<StripeConfig>,
33 49 /// Admin user ID for waitlist management (optional)
@@ -326,6 +342,28 @@
326 342 })
327 343 });
328 344
345 + // The RPM repo bucket. Two ways in, and the prefixed one wins: a
346 + // dedicated `RPM_S3_*` credential is the shape the provisioning task
347 + // (alloy `23f599d9`) hands over, and `S3_RPM_BUCKET` over the main
348 + // credentials is the same fallback `public_storage` takes above, so a
349 + // bucket in the same project needs one variable rather than five.
350 + let rpm_storage = StorageConfig::from_env_prefixed("RPM_S3_").or_else(|| {
351 + std::env::var("S3_RPM_BUCKET")
352 + .ok()
353 + .filter(|s| !s.is_empty())
354 + .and_then(|bucket| {
355 + storage.as_ref().map(|s| StorageConfig {
356 + bucket,
357 + ..s.clone()
358 + })
359 + })
360 + });
361 +
362 + let rpm_base_url = std::env::var("RPM_BASE_URL")
363 + .ok()
364 + .filter(|s| !s.is_empty())
365 + .map(|s| s.trim_end_matches('/').to_string());
366 +
329 367 // Load Stripe config - optional, returns None if not fully configured
330 368 let stripe = StripeConfig::from_env();
331 369
@@ -548,6 +586,8 @@
548 586 storage,
549 587 synckit_storage,
550 588 public_storage,
589 + rpm_storage,
590 + rpm_base_url,
551 591 stripe,
552 592 admin_user_id,
553 593 synckit_jwt_secret,
@@ -996,6 +1036,13 @@
996 1036 "S3_SECRET_KEY",
997 1037 "S3_REGION",
998 1038 "S3_PUBLIC_BUCKET",
1039 + "S3_RPM_BUCKET",
1040 + "RPM_S3_ENDPOINT",
1041 + "RPM_S3_BUCKET",
1042 + "RPM_S3_ACCESS_KEY",
1043 + "RPM_S3_SECRET_KEY",
1044 + "RPM_S3_REGION",
1045 + "RPM_BASE_URL",
999 1046 "SYNCKIT_S3_ENDPOINT",
1000 1047 "SYNCKIT_S3_BUCKET",
1001 1048 "SYNCKIT_S3_ACCESS_KEY",
@@ -1109,6 +1156,8 @@
1109 1156 storage: None,
1110 1157 synckit_storage: None,
1111 1158 public_storage: None,
1159 + rpm_storage: None,
1160 + rpm_base_url: None,
1112 1161 stripe: None,
1113 1162 admin_user_id: None,
1114 1163 synckit_jwt_secret: None,
@@ -420,6 +420,27 @@
420 420 pub const OTA_READ_RATE_LIMIT_MS: u64 = 100;
421 421 pub const OTA_READ_RATE_LIMIT_BURST: u32 = 30;
422 422
423 + // Alloy hotfix RPM repo publishing (routes::rpm).
424 + /// Presign lifetime for an RPM/repodata PUT. Matches the OTA artifact window:
425 + /// the object is uploaded immediately after the mint, and a short window bounds
426 + /// what a leaked URL is worth.
427 + pub const RPM_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour
428 + /// Largest object the RPM publish endpoint will sign. A single unresumable PUT,
429 + /// so it stays modest; the biggest thing the repo carries is one package, and a
430 + /// package near this size is a packaging mistake rather than a hotfix.
431 + pub const RPM_MAX_OBJECT_BYTES: i64 = 2 * 1024 * 1024 * 1024; // 2 GB
432 + /// Longest object path the endpoint will accept, counted in bytes over the
433 + /// whole key. Well under S3's 1024-byte key limit and far past any real
434 + /// `repodata/<sha256>-primary.xml.zst`.
435 + pub const RPM_MAX_KEY_BYTES: usize = 255;
436 + /// Most path segments an RPM object key may carry: `alloy/f43/x86_64/repodata/repomd.xml`
437 + /// is five.
438 + pub const RPM_MAX_KEY_SEGMENTS: usize = 8;
439 + // RPM publish: burst 20, then 4/sec. A repodata push is several objects back to
440 + // back, so the burst is wider than OTA's while the steady rate stays low.
441 + pub const RPM_WRITE_RATE_LIMIT_MS: u64 = 250;
442 + pub const RPM_WRITE_RATE_LIMIT_BURST: u32 = 20;
443 +
423 444 // Build pipeline
424 445 pub const BUILD_TIMEOUT_SECS: u64 = 1800; // 30 min
425 446 pub const BUILD_MAX_LOG_BYTES: usize = 5_242_880; // 5 MB
@@ -94,8 +94,8 @@
94 94 use payments::PaymentProvider;
95 95 use routes::{
96 96 admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes,
97 - git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes,
98 - storage_routes, stripe_routes, synckit_routes,
97 + git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, rpm_routes,
98 + sso_routes, storage_routes, stripe_routes, synckit_routes,
99 99 };
100 100 use scanning::ScanPipeline;
101 101 use storage::StorageBackend;
@@ -169,6 +169,11 @@
169 169 /// gallery, item/project images); the scan worker copies Clean image
170 170 /// objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset.
171 171 pub public_s3: Option<Arc<dyn StorageBackend>>,
172 + /// Bucket holding the Alloy hotfix RPM repository. Written only by the
173 + /// admin publish endpoint (`routes::rpm`); read by nobody here, because the
174 + /// repo is fetched straight off the bucket through Caddy. `None` when the
175 + /// bucket is unconfigured, which is every dev environment.
176 + pub rpm_s3: Option<Arc<dyn StorageBackend>>,
172 177 }
173 178
174 179 /// Derived in-memory caches held by [`AppState`]. All are `Arc<DashMap>` so a
@@ -434,6 +439,13 @@
434 439 )
435 440 })
436 441 }
442 +
443 + /// Get the RPM-repo S3 storage backend, or error if not configured.
444 + pub fn require_rpm_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
445 + self.rpm_s3.as_ref().ok_or_else(|| {
446 + error::AppError::ServiceUnavailable("RPM bucket is not configured".to_string())
447 + })
448 + }
437 449 }
438 450
439 451 impl AppState {
@@ -552,6 +564,7 @@
552 564 .merge(git_issue_routes())
553 565 .merge(git_write_routes())
554 566 .merge(ota_routes())
567 + .merge(rpm_routes())
555 568 .merge(build_routes());
556 569 // The description layer, when a screen is switched on. Inside the CSRF tree
557 570 // rather than beside it, so a described write is covered by the same
@@ -402,6 +402,29 @@
402 402 None
403 403 };
404 404
405 + // Initialize the RPM-repo bucket client if configured. Written only by the
406 + // admin publish endpoint; served to dnf/rpm-ostree by Caddy off the bucket,
407 + // so nothing here reads it back.
408 + let rpm_s3: Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> = if let Some(
409 + ref rpm_storage_config,
410 + ) =
411 + config.rpm_storage
412 + {
413 + match S3Client::new(rpm_storage_config, &config.host_url).await {
414 + Ok(client) => {
415 + tracing::info!(bucket = %rpm_storage_config.bucket, "RPM S3 bucket initialized");
416 + Some(std::sync::Arc::new(client))
417 + }
418 + Err(e) => {
419 + tracing::warn!(error = ?e, "Failed to initialize RPM S3 bucket");
420 + None
421 + }
422 + }
423 + } else {
424 + tracing::info!("RPM S3 bucket not configured");
425 + None
426 + };
427 +
405 428 // Initialize Stripe client if configured
406 429 let stripe: Option<std::sync::Arc<dyn makenotwork::payments::PaymentProvider>> = if let Some(
407 430 ref stripe_config,
@@ -557,6 +580,7 @@
557 580 s3,
558 581 synckit_s3,
559 582 public_s3,
583 + rpm_s3,
560 584 },
561 585 stripe,
562 586 email,
@@ -8,6 +8,7 @@
8 8 use std::str::FromStr;
9 9
10 10 use crate::config::StorageConfig;
11 + use crate::constants;
11 12 use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId};
12 13 use crate::error::{AppError, Result};
13 14
@@ -163,6 +164,15 @@
163 164 const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
164 165 const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
165 166
167 + /// Extensions an Alloy hotfix RPM repository serves. The package itself, the
168 + /// `createrepo_c` metadata under `repodata/` (XML, in whatever compression the
169 + /// generator chose, or the sqlite variants), and the detached signature and
170 + /// public key that go beside `repomd.xml`. Anything else is a publish mistake:
171 + /// nothing in `dnf`'s fetch path asks for it, so serving it is pure surface.
172 + const RPM_REPO_EXTENSIONS: &[&str] = &[
173 + "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml",
174 + ];
175 +
166 176 /// Default presigned URL expiration.
167 177 /// 1 hour balances usability (large uploads over slow connections) against
168 178 /// security (limiting the window for URL leakage). Overridable per-call.
@@ -672,6 +682,87 @@
672 682 ))
673 683 }
674 684
685 + /// Key for an object in the Alloy hotfix RPM repository, from the relative
686 + /// path the publisher names (e.g. `alloy/f43/x86_64/repodata/repomd.xml`).
687 + ///
688 + /// The odd one out among the generators, and deliberately so: every other
689 + /// key layout here is derived from ids we hold, but a yum repository *is* a
690 + /// path layout that `createrepo_c` writes and `dnf` re-derives from
691 + /// `repomd.xml`. The server cannot invent it without reimplementing
692 + /// createrepo, so the caller supplies it. That makes this the one generator
693 + /// whose whole job is refusing bad input, and it returns `Result` for that
694 + /// reason. Which layout the repo actually uses is
695 + /// [`86cb87b9`](https://makenot.work)'s business, not this function's, hence
696 + /// no structure is imposed beyond a segment count.
697 + ///
698 + /// Refused: absolute paths, empty segments (so `//` and a trailing `/`),
699 + /// `.` and `..` in any position, a segment starting `.` or `-`, anything
700 + /// outside `[A-Za-z0-9._+~-]`, and a final segment whose extension is not
701 + /// one a yum repository serves. Together those make traversal
702 + /// unrepresentable rather than merely unlikely, and keep a presigned PUT
703 + /// from writing an object the Caddy block would then serve as something it
704 + /// is not.
705 + pub fn generate_rpm_key(path: &str) -> Result<S3Key> {
706 + let bad = |msg: &str| AppError::BadRequest(format!("invalid RPM object path: {msg}"));
707 +
708 + if path.is_empty() {
709 + return Err(bad("empty"));
710 + }
711 + if path.len() > constants::RPM_MAX_KEY_BYTES {
712 + return Err(bad(&format!(
713 + "longer than {} bytes",
714 + constants::RPM_MAX_KEY_BYTES
715 + )));
716 + }
717 + if path.starts_with('/') {
718 + return Err(bad("must be relative, not absolute"));
719 + }
720 +
721 + let segments: Vec<&str> = path.split('/').collect();
722 + if segments.len() > constants::RPM_MAX_KEY_SEGMENTS {
723 + return Err(bad(&format!(
724 + "more than {} path segments",
725 + constants::RPM_MAX_KEY_SEGMENTS
726 + )));
727 + }
728 +
729 + for segment in &segments {
730 + if segment.is_empty() {
731 + return Err(bad("empty path segment"));
732 + }
733 + if *segment == "." || *segment == ".." {
734 + return Err(bad("`.` and `..` are not path segments"));
735 + }
736 + if segment.starts_with('.') || segment.starts_with('-') {
737 + return Err(bad("a path segment may not start with `.` or `-`"));
738 + }
739 + if !segment
740 + .chars()
741 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-'))
742 + {
743 + return Err(bad(
744 + "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`",
745 + ));
746 + }
747 + }
748 +
749 + // Unwrap: `split` on a non-empty string always yields at least one
750 + // segment, and every segment was proven non-empty above.
751 + let filename = segments.last().copied().unwrap_or_default();
752 + let ext = filename
753 + .rsplit_once('.')
754 + .map(|(_, ext)| ext.to_ascii_lowercase())
755 + .ok_or_else(|| bad("the final path segment needs a file extension"))?;
756 + if !RPM_REPO_EXTENSIONS.contains(&ext.as_str()) {
757 + return Err(bad(&format!(
758 + "`.{ext}` is not served from an RPM repository. Allowed: {}",
759 + RPM_REPO_EXTENSIONS.join(", ")
760 + )));
761 + }
762 +
763 + Ok(S3Key(path.to_string()))
764 + }
765 +
675 766 /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's
676 767 /// name *is* its content hash, so the served bytes are provably the bytes
677 768 /// that were scanned, a swapped object would hash to a different key. The
@@ -12,6 +12,7 @@
12 12 pub mod ota;
13 13 pub mod pages;
14 14 pub mod postmark;
15 + pub mod rpm;
15 16 pub mod sso;
16 17 pub mod storage;
17 18 pub mod stripe;
@@ -29,6 +30,7 @@
29 30 pub use ota::ota_routes;
30 31 pub use pages::page_routes;
31 32 pub use postmark::postmark_routes;
33 + pub use rpm::rpm_routes;
32 34 pub use sso::sso_routes;
33 35 pub use storage::storage_routes;
34 36 pub use stripe::stripe_routes;