Skip to main content

max / makenotwork

18.5 KB · 520 lines History Blame Raw
1 //! `mnw-cli ota publish` — typed OTA release publisher.
2 //!
3 //! Replaces the old `server/deploy/ota-publish.sh`. Authenticates against the
4 //! MNW SyncKit API, creates a release, registers the artifact (which returns a
5 //! presigned S3 PUT URL), uploads the bytes, and verifies the public Tauri
6 //! updater endpoint now serves it.
7 //!
8 //! Invoked as `mnw-cli ota publish [flags]` — `main()` routes here before the
9 //! SSH daemon starts when the first argument is `ota`.
10
11 use std::path::PathBuf;
12
13 use anyhow::{Context, Result, bail};
14 use synckit_client::{SyncKitClient, SyncKitConfig};
15
16 const DEFAULT_SERVER: &str = "https://makenot.work";
17 const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"];
18 const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"];
19
20 /// Entry point for the `ota` subcommand. `rest` is everything after `ota`.
21 pub(crate) async fn run(rest: &[String]) -> Result<()> {
22 match rest.first().map(String::as_str) {
23 Some("publish") => publish(&rest[1..]).await,
24 Some("-h" | "--help") | None => {
25 print_usage();
26 Ok(())
27 }
28 Some(other) => {
29 eprintln!("Unknown ota subcommand: {other}\n");
30 print_usage();
31 std::process::exit(2);
32 }
33 }
34 }
35
36 fn print_usage() {
37 eprintln!(
38 "Usage: mnw-cli ota publish --slug SLUG --version X.Y.Z --target OS --arch ARCH --artifact FILE\n\
39 \n\
40 Required:\n\
41 \x20 --slug App slug (e.g. goingson, audiofiles)\n\
42 \x20 --version Semver version (e.g. 0.4.1)\n\
43 \x20 --target Target OS: {}\n\
44 \x20 --arch Architecture: {}\n\
45 \x20 --artifact Path to the built artifact file\n\
46 \n\
47 \x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\
48 \x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\
49 \n\
50 Auth: defaults to MNW OAuth (opens a browser on this machine; required\n\
51 for accounts with 2FA). Pass --password (+ --email) to use password auth.\n\
52 \n\
53 Optional:\n\
54 \x20 --notes Release notes (default: empty)\n\
55 \x20 --signature Minisign signature for Tauri verification (REQUIRED for a working update)\n\
56 \x20 --release-id Attach to an existing release UUID instead of creating one\n\
57 \x20 (resume a publish whose artifact upload failed)\n\
58 \x20 --email MNW account email (env MNW_OTA_EMAIL; password auth only)\n\
59 \x20 --password MNW account password (env MNW_OTA_PASSWORD; enables password auth)\n\
60 \x20 --server Server URL (env MNW_OTA_SERVER, default {DEFAULT_SERVER})",
61 ALLOWED_TARGETS.join(", "),
62 ALLOWED_ARCHS.join(", "),
63 );
64 }
65
66 struct PublishArgs {
67 slug: String,
68 version: String,
69 target: String,
70 arch: String,
71 artifact: PathBuf,
72 notes: String,
73 signature: String,
74 // When set, attach the artifact to this existing release instead of creating
75 // one (resume a failed upload; create would 409 on a duplicate version).
76 release_id: Option<String>,
77 // email + password drive the legacy password auth path. When absent, the
78 // publisher uses the interactive MNW OAuth flow (the default; required for
79 // accounts with 2FA, which the password endpoint rejects).
80 email: Option<String>,
81 password: Option<String>,
82 api_key: String,
83 key: String,
84 server: String,
85 }
86
87 // Manual Debug that redacts the credentials so they never reach logs or a
88 // failing-test backtrace.
89 impl std::fmt::Debug for PublishArgs {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("PublishArgs")
92 .field("slug", &self.slug)
93 .field("version", &self.version)
94 .field("target", &self.target)
95 .field("arch", &self.arch)
96 .field("artifact", &self.artifact)
97 .field("notes", &self.notes)
98 .field("signature", &self.signature)
99 .field("release_id", &self.release_id)
100 .field("email", &self.email)
101 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
102 .field("api_key", &"<redacted>")
103 .field("key", &"<redacted>")
104 .field("server", &self.server)
105 .finish()
106 }
107 }
108
109 /// Parse flags with environment-variable fallbacks for the credentials.
110 fn parse_args(flags: &[String]) -> Result<PublishArgs> {
111 let mut slug = None;
112 let mut version = None;
113 let mut target = None;
114 let mut arch = None;
115 let mut artifact = None;
116 let mut notes = String::new();
117 let mut signature = String::new();
118 let mut release_id = None;
119 let mut email = std::env::var("MNW_OTA_EMAIL").ok();
120 let mut password = std::env::var("MNW_OTA_PASSWORD").ok();
121 let mut api_key = std::env::var("MNW_OTA_API_KEY").ok();
122 let mut key = std::env::var("MNW_OTA_KEY").ok();
123 let mut server = std::env::var("MNW_OTA_SERVER").unwrap_or_else(|_| DEFAULT_SERVER.to_string());
124
125 let mut it = flags.iter();
126 while let Some(flag) = it.next() {
127 let mut take = |name: &str| -> Result<String> {
128 it.next()
129 .cloned()
130 .with_context(|| format!("{name} requires a value"))
131 };
132 match flag.as_str() {
133 "--slug" => slug = Some(take("--slug")?),
134 "--version" => version = Some(take("--version")?),
135 "--target" => target = Some(take("--target")?),
136 "--arch" => arch = Some(take("--arch")?),
137 "--artifact" => artifact = Some(PathBuf::from(take("--artifact")?)),
138 "--notes" => notes = take("--notes")?,
139 "--signature" => signature = take("--signature")?,
140 "--release-id" => release_id = Some(take("--release-id")?),
141 "--email" => email = Some(take("--email")?),
142 "--password" => password = Some(take("--password")?),
143 "--api-key" => api_key = Some(take("--api-key")?),
144 "--key" => key = Some(take("--key")?),
145 "--server" => server = take("--server")?,
146 "-h" | "--help" => {
147 print_usage();
148 std::process::exit(0);
149 }
150 other => bail!("Unknown flag: {other}"),
151 }
152 }
153
154 let missing = |name: &str| anyhow::anyhow!("missing required {name}");
155 let target = target.ok_or_else(|| missing("--target"))?;
156 let arch = arch.ok_or_else(|| missing("--arch"))?;
157
158 if !ALLOWED_TARGETS.contains(&target.as_str()) {
159 bail!(
160 "invalid --target '{target}'. Allowed: {}",
161 ALLOWED_TARGETS.join(", ")
162 );
163 }
164 if !ALLOWED_ARCHS.contains(&arch.as_str()) {
165 bail!(
166 "invalid --arch '{arch}'. Allowed: {}",
167 ALLOWED_ARCHS.join(", ")
168 );
169 }
170
171 Ok(PublishArgs {
172 slug: slug.ok_or_else(|| missing("--slug"))?,
173 version: version.ok_or_else(|| missing("--version"))?,
174 target,
175 arch,
176 artifact: artifact.ok_or_else(|| missing("--artifact"))?,
177 notes,
178 signature,
179 release_id,
180 email, // optional: only used by the password auth path
181 password,
182 api_key: api_key.ok_or_else(|| missing("--api-key / MNW_OTA_API_KEY"))?,
183 key: key.ok_or_else(|| missing("--key / MNW_OTA_KEY"))?,
184 server,
185 })
186 }
187
188 async fn publish(flags: &[String]) -> Result<()> {
189 let args = parse_args(flags)?;
190
191 // Async read: a blocking `std::fs::read` of a multi-hundred-MB artifact
192 // stalls the runtime for the whole read on every publish.
193 let bytes = tokio::fs::read(&args.artifact)
194 .await
195 .with_context(|| format!("reading artifact {}", args.artifact.display()))?;
196 let file_size: i64 = bytes
197 .len()
198 .try_into()
199 .context("artifact is too large to publish")?;
200 if file_size == 0 {
201 bail!("artifact is empty: {}", args.artifact.display());
202 }
203
204 if args.signature.trim().is_empty() {
205 eprintln!(
206 "warning: --signature is empty. Tauri's updater silently refuses an update with no \
207 signature, so installed apps will NOT apply this release. Publish with the minisign \
208 signature of the artifact for a working update."
209 );
210 }
211
212 println!(
213 "Publishing {} v{} ({}/{}, {} bytes) to {}",
214 args.slug, args.version, args.target, args.arch, file_size, args.server
215 );
216
217 let client = SyncKitClient::new(SyncKitConfig {
218 server_url: args.server.clone(),
219 api_key: args.api_key.clone(),
220 });
221
222 // Default to MNW OAuth; fall back to password auth only when a password is
223 // supplied. OAuth is required for accounts with 2FA (the password endpoint
224 // rejects them) and keeps the password out of env/argv.
225 match &args.password {
226 Some(password) => {
227 let email = args
228 .email
229 .as_deref()
230 .context("--email / MNW_OTA_EMAIL is required with password auth")?;
231 print!(" authenticating (password)... ");
232 client
233 .authenticate(email, password, &args.key)
234 .await
235 .context("authentication failed")?;
236 println!("ok");
237 }
238 None => {
239 authenticate_oauth(&client, &args.key).await?;
240 }
241 }
242 let app_id = client
243 .session_info()
244 .map(|s| s.app_id.to_string())
245 .unwrap_or_default();
246 println!(" authenticated (app {app_id})");
247
248 // With --release-id, attach to an existing release (resume a failed upload)
249 // instead of creating one — create would 409 on a duplicate version.
250 let release_id = match &args.release_id {
251 Some(rid) => {
252 let id = rid
253 .parse::<uuid::Uuid>()
254 .context("--release-id must be a UUID")?;
255 println!(" using existing release {id} (skipping create)");
256 id
257 }
258 None => {
259 print!(" creating release v{}... ", args.version);
260 let release = client
261 .ota_create_release(&args.version, &args.notes)
262 .await
263 .context("create release failed")?;
264 println!("ok (release {})", release.id);
265 release.id
266 }
267 };
268
269 print!(" registering artifact... ");
270 let upload = client
271 .ota_register_artifact(
272 release_id,
273 &args.target,
274 &args.arch,
275 file_size,
276 &args.signature,
277 )
278 .await
279 .context("register artifact failed")?;
280 println!("ok ({})", upload.s3_key);
281
282 print!(" uploading {file_size} bytes... ");
283 client
284 .ota_upload_artifact(&upload.upload_url, bytes)
285 .await
286 .context("artifact upload failed")?;
287 println!("ok");
288
289 print!(" confirming artifact (queues malware scan)... ");
290 client
291 .ota_confirm_artifact(release_id, &args.target, &args.arch)
292 .await
293 .context("artifact confirm failed")?;
294 println!("ok");
295
296 print!(" verifying updater endpoint... ");
297 match client
298 .ota_updater_check(&args.slug, &args.target, &args.arch, "0.0.1")
299 .await
300 .context("updater check failed")?
301 {
302 Some(manifest) if manifest.version == args.version => {
303 println!("ok (serving v{})", manifest.version);
304 }
305 Some(manifest) => {
306 println!(
307 "warning: updater serves v{} but just published v{} (a newer release may exist)",
308 manifest.version, args.version
309 );
310 }
311 None => {
312 println!(
313 "pending scan (204). The artifact was uploaded and queued for malware \
314 scanning; it will be served for {}/{} once the scan clears.",
315 args.target, args.arch
316 );
317 }
318 }
319
320 println!(
321 "\nPublished {} v{} ({}/{})\nUpdater URL: {}/api/v1/sync/ota/{}/{}/{}/{}",
322 args.slug,
323 args.version,
324 args.target,
325 args.arch,
326 args.server.trim_end_matches('/'),
327 args.slug,
328 args.target,
329 args.arch,
330 args.version,
331 );
332 Ok(())
333 }
334
335 /// Drive the MNW OAuth2 PKCE flow: bind a localhost redirect listener, open the
336 /// browser to the authorize URL, capture the returned code, and exchange it for
337 /// a session token. The browser must run on the same machine as this command
338 /// (the redirect targets `http://127.0.0.1:<port>/`).
339 async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> {
340 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
341 .await
342 .context("failed to bind a localhost listener for the OAuth redirect")?;
343 let port = listener.local_addr()?.port();
344
345 let pkce = synckit_client::generate_pkce();
346 let state = synckit_client::generate_oauth_state();
347 let url = client.build_authorize_url(port, &state, &pkce.challenge);
348
349 println!("\n authenticating via MNW OAuth.");
350 println!(" Open this URL in a browser on THIS machine and approve:");
351 println!(" {url}");
352 open_browser(&url);
353 println!(" waiting for the authorization redirect (5 min timeout)...");
354
355 let (code, got_state) =
356 tokio::time::timeout(std::time::Duration::from_mins(5), wait_for_code(&listener))
357 .await
358 .context("timed out waiting for OAuth authorization")??;
359
360 if got_state.as_deref() != Some(state.as_str()) {
361 bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)");
362 }
363
364 client
365 .authenticate_with_code(&code, &pkce.verifier, port, key)
366 .await
367 .context("OAuth code exchange failed")?;
368 Ok(())
369 }
370
371 /// Accept localhost connections until one carries an OAuth `code` (ignoring
372 /// incidental requests like `/favicon.ico`), reply with a small page, and return
373 /// `(code, state)`.
374 async fn wait_for_code(listener: &tokio::net::TcpListener) -> Result<(String, Option<String>)> {
375 use tokio::io::AsyncReadExt;
376 loop {
377 let (mut sock, _) = listener.accept().await.context("accept failed")?;
378 let mut buf = [0u8; 4096];
379 let n = sock.read(&mut buf).await.unwrap_or(0);
380 let req = String::from_utf8_lossy(&buf[..n]);
381 let target = req
382 .lines()
383 .next()
384 .and_then(|line| line.split_whitespace().nth(1))
385 .unwrap_or("");
386 let query = target.split_once('?').map_or("", |(_, q)| q);
387
388 let (mut code, mut state, mut oauth_err) = (None, None, None);
389 for pair in query.split('&') {
390 match pair.split_once('=') {
391 // `code` (hex) and `state` (base64url) are URL-safe — no decode needed.
392 Some(("code", v)) => code = Some(v.to_string()),
393 Some(("state", v)) => state = Some(v.to_string()),
394 Some(("error", v)) => oauth_err = Some(v.to_string()),
395 _ => {}
396 }
397 }
398
399 if let Some(e) = oauth_err {
400 let _ = respond(&mut sock, "Authorization failed. You can close this tab.").await;
401 bail!("authorization was denied: {e}");
402 }
403 if let Some(c) = code {
404 let _ = respond(
405 &mut sock,
406 "Authorization complete. You can close this tab and return to the terminal.",
407 )
408 .await;
409 return Ok((c, state));
410 }
411 // Incidental request (favicon, etc.) — answer and keep waiting.
412 let _ = respond(&mut sock, "Waiting for authorization...").await;
413 }
414 }
415
416 /// Write a minimal HTML 200 response and close the connection.
417 async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Result<()> {
418 use tokio::io::AsyncWriteExt;
419 let body = format!(
420 "<!doctype html><meta charset=utf-8><body style=\"font-family:system-ui;padding:2rem\"><p>{message}</p></body>"
421 );
422 let resp = format!(
423 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
424 body.len(),
425 body
426 );
427 sock.write_all(resp.as_bytes()).await?;
428 sock.flush().await
429 }
430
431 /// Best-effort browser open; the URL is also printed for manual use (e.g. over SSH).
432 fn open_browser(url: &str) {
433 let opener = if cfg!(target_os = "macos") {
434 "open"
435 } else {
436 "xdg-open"
437 };
438 let _ = std::process::Command::new(opener)
439 .arg(url)
440 .stdout(std::process::Stdio::null())
441 .stderr(std::process::Stdio::null())
442 .spawn();
443 }
444
445 #[cfg(test)]
446 mod tests {
447 use super::*;
448
449 fn base_flags() -> Vec<String> {
450 [
451 "--slug",
452 "goingson",
453 "--version",
454 "0.4.1",
455 "--target",
456 "darwin",
457 "--arch",
458 "aarch64",
459 "--artifact",
460 "/tmp/x",
461 "--email",
462 "me@example.com",
463 "--password",
464 "pw",
465 "--api-key",
466 "ak",
467 "--key",
468 "sdk",
469 "--server",
470 "https://example.test",
471 ]
472 .iter()
473 .map(std::string::ToString::to_string)
474 .collect()
475 }
476
477 #[test]
478 fn parses_full_flag_set() {
479 let a = parse_args(&base_flags()).unwrap();
480 assert_eq!(a.slug, "goingson");
481 assert_eq!(a.version, "0.4.1");
482 assert_eq!(a.target, "darwin");
483 assert_eq!(a.arch, "aarch64");
484 assert_eq!(a.server, "https://example.test");
485 assert!(a.notes.is_empty());
486 }
487
488 #[test]
489 fn rejects_invalid_target() {
490 let mut flags = base_flags();
491 let i = flags.iter().position(|f| f == "darwin").unwrap();
492 flags[i] = "macos".to_string();
493 let err = parse_args(&flags).unwrap_err().to_string();
494 assert!(err.contains("invalid --target"), "{err}");
495 }
496
497 #[test]
498 fn rejects_invalid_arch() {
499 let mut flags = base_flags();
500 let i = flags.iter().position(|f| f == "aarch64").unwrap();
501 flags[i] = "arm64".to_string();
502 let err = parse_args(&flags).unwrap_err().to_string();
503 assert!(err.contains("invalid --arch"), "{err}");
504 }
505
506 #[test]
507 fn missing_required_flag_is_reported() {
508 // Drop the trailing --server pair and the --slug pair.
509 let flags: Vec<String> = base_flags().into_iter().skip(2).collect(); // skip --slug goingson
510 let err = parse_args(&flags).unwrap_err().to_string();
511 assert!(err.contains("--slug"), "{err}");
512 }
513
514 #[test]
515 fn flag_without_value_errors() {
516 let err = parse_args(&["--slug".to_string()]).unwrap_err().to_string();
517 assert!(err.contains("--slug requires a value"), "{err}");
518 }
519 }
520