Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions src/commands/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
};
Expand All @@ -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;
});
}
Expand All @@ -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);
Expand Down Expand Up @@ -170,6 +190,7 @@ fn open_tunnel(
module_id: &module_id,
local_url,
version: env!("CARGO_PKG_VERSION"),
internal_secret: Some(internal_secret),
},
)
.await
Expand Down Expand Up @@ -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<String> {
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<String> {
if let Some(url) = &args.db_url {
return Ok(url.clone());
Expand Down
34 changes: 19 additions & 15 deletions src/commands/dev/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,28 @@ use anyhow::{Context, Result, anyhow};

use super::line_prefix;

/// Run `go run .` in `dir` with `MS_LOCAL_DB_URL=<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=<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")?;
Expand Down
24 changes: 24 additions & 0 deletions src/commands/dev/tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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]
Expand Down
Loading