diff --git a/src/commands/dev/mod.rs b/src/commands/dev/mod.rs index df391cb..f7101d1 100644 --- a/src/commands/dev/mod.rs +++ b/src/commands/dev/mod.rs @@ -18,8 +18,12 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use anyhow::{Context, Result, anyhow}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use clap::Args; use console::style; +use rand::TryRngCore; +use rand::rngs::OsRng; use reqwest::blocking::Client; use super::{ @@ -85,8 +89,22 @@ pub fn run(args: DevArgs) -> Result<()> { // Bring the tunnel up BEFORE compose so that a tunnel registration // failure (auth expired, dispatch unreachable) doesn't waste the user's // time bringing containers up first. - let tunnel_handle = if args.tunnel { - Some(open_tunnel(&cwd, args.local_url.as_deref())?) + // + // Tunnel mode exposes the module to remote callers (via dispatch's + // Leaf-1 307), so the module MUST enforce Internal-scope auth. Mint a + // per-session secret, set MS_INTERNAL_SECRET on the spawned module + // process (which flips the bypass-vs-enforce matrix in + // auth/middleware.go to enforce), and ship the same value to dispatch + // in the register frame so dispatch can attach X-MS-Internal-Secret on + // forwarded requests. + let tunnel = if args.tunnel { + let secret = mint_internal_secret()?; + let (handle, runtime) = open_tunnel(&cwd, args.local_url.as_deref(), &secret)?; + Some(Tunnel { + handle, + runtime, + internal_secret: secret, + }) } else { None }; @@ -97,12 +115,13 @@ pub fn run(args: DevArgs) -> Result<()> { } eprintln!("{} module running — Ctrl-C to stop", ok_mark()); - let module_status = process::run_module(&cwd, &db_url); + let internal_secret = tunnel.as_ref().map(|t| t.internal_secret.as_str()); + let module_status = process::run_module(&cwd, &db_url, internal_secret); - if let Some((handle, runtime)) = tunnel_handle { - handle.shutdown(); + if let Some(t) = tunnel { + t.handle.shutdown(); // Block on the runtime briefly to let the close frame land. - runtime.block_on(async { + t.runtime.block_on(async { tokio::time::sleep(Duration::from_millis(200)).await; }); } @@ -126,6 +145,7 @@ pub fn run(args: DevArgs) -> Result<()> { fn open_tunnel( module_dir: &Path, local_url: Option<&str>, + internal_secret: &str, ) -> Result<(tunnel::TunnelHandle, tokio::runtime::Runtime)> { let mut creds = credentials::load_or_login_hint()?; let dispatch_base = resolve_base(ENV_DISPATCH_URL, DEFAULT_DISPATCH_BASE); @@ -170,6 +190,7 @@ fn open_tunnel( module_id: &module_id, local_url, version: env!("CARGO_PKG_VERSION"), + internal_secret: Some(internal_secret), }, ) .await @@ -281,6 +302,26 @@ fn dev_console_url(dispatch_base: &str, slug: Option<&str>) -> String { } } +/// Held for the lifetime of `mirrorstack dev --tunnel`. The runtime +/// keeps the WSS background task alive; the secret is the +/// `MS_INTERNAL_SECRET` value the spawned module enforces against. +struct Tunnel { + handle: tunnel::TunnelHandle, + runtime: tokio::runtime::Runtime, + internal_secret: String, +} + +/// Mint a 32-byte URL-safe base64 random token used as the module's +/// MS_INTERNAL_SECRET. Same shape as auth::random_state (OsRng → b64url), +/// inlined here to avoid a cross-module dependency for a one-off helper. +fn mint_internal_secret() -> Result { + let mut buf = [0u8; 32]; + OsRng + .try_fill_bytes(&mut buf) + .context("dev: mint tunnel secret")?; + Ok(URL_SAFE_NO_PAD.encode(buf)) +} + fn resolve_db_url(args: &DevArgs) -> Result { if let Some(url) = &args.db_url { return Ok(url.clone()); diff --git a/src/commands/dev/process.rs b/src/commands/dev/process.rs index 4d33044..5f23410 100644 --- a/src/commands/dev/process.rs +++ b/src/commands/dev/process.rs @@ -21,24 +21,28 @@ use anyhow::{Context, Result, anyhow}; use super::line_prefix; -/// Run `go run .` in `dir` with `MS_LOCAL_DB_URL=`. Blocks until -/// the child exits, or until Ctrl-C is received and the child has been -/// asked to terminate. Returns Ok on a clean child exit, Err on non-zero -/// status or signal-driven termination. -pub(super) fn run_module(dir: &Path, db_url: &str) -> Result<()> { - let mut child = Command::new("go") - .args(["run", "."]) +/// Run `go run .` in `dir` with `MS_LOCAL_DB_URL=`. When +/// `internal_secret` is `Some`, also sets `MS_INTERNAL_SECRET` — used +/// by tunnel mode so the SDK's InternalAuth flips from bypass to +/// enforce. Blocks until the child exits, or until Ctrl-C is received +/// and the child has been asked to terminate. Returns Ok on a clean +/// child exit, Err on non-zero status or signal-driven termination. +pub(super) fn run_module(dir: &Path, db_url: &str, internal_secret: Option<&str>) -> Result<()> { + let mut cmd = Command::new("go"); + cmd.args(["run", "."]) .current_dir(dir) .env("MS_LOCAL_DB_URL", db_url) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => { - anyhow!("`go` not found on PATH. Install Go 1.26+ before running dev.") - } - _ => anyhow!("dev: spawn `go run .`: {e}"), - })?; + .stderr(Stdio::piped()); + if let Some(secret) = internal_secret { + cmd.env("MS_INTERNAL_SECRET", secret); + } + let mut child = cmd.spawn().map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + anyhow!("`go` not found on PATH. Install Go 1.26+ before running dev.") + } + _ => anyhow!("dev: spawn `go run .`: {e}"), + })?; let stdout = child.stdout.take().context("dev: child stdout")?; let stderr = child.stderr.take().context("dev: child stderr")?; diff --git a/src/commands/dev/tunnel.rs b/src/commands/dev/tunnel.rs index 0d42280..7de7bfd 100644 --- a/src/commands/dev/tunnel.rs +++ b/src/commands/dev/tunnel.rs @@ -124,6 +124,15 @@ pub(super) struct RegisterPayload<'a> { pub module_id: &'a str, pub local_url: &'a str, pub version: &'a str, + /// Per-session shared secret the module enforces on its Internal + /// scope routes (X-MS-Internal-Secret header). The CLI mints this, + /// sets `MS_INTERNAL_SECRET` on the spawned module process, and + /// sends the same value here so dispatch can attach the header to + /// every forwarded request. None until --tunnel is set; serialized + /// only when present so older dispatch builds keep round-tripping + /// the register frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub internal_secret: Option<&'a str>, } #[derive(Debug, Deserialize)] @@ -426,10 +435,25 @@ mod tests { module_id: "m_abc", local_url: "http://localhost:8080", version: "0.1.0", + internal_secret: None, }; let s = serde_json::to_string(&p).unwrap(); assert!(s.contains("\"module_id\":\"m_abc\"")); assert!(s.contains("\"local_url\":\"http://localhost:8080\"")); + // Skip-if-none keeps the field absent for older dispatch builds. + assert!(!s.contains("internal_secret")); + } + + #[test] + fn register_payload_emits_internal_secret_when_set() { + let p = RegisterPayload { + module_id: "m_abc", + local_url: "http://localhost:8080", + version: "0.1.0", + internal_secret: Some("s3cret"), + }; + let s = serde_json::to_string(&p).unwrap(); + assert!(s.contains("\"internal_secret\":\"s3cret\"")); } #[test]