use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use serde::Deserialize;
pub const ENGINE_PROTOCOL: u32 = 1;
pub const DEFAULT_MGMT_BASE: &str = "http://127.0.0.1:8765";
const CALL_TIMEOUT: Duration = Duration::from_millis(1500);
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct BillingInfo {
#[serde(default)]
pub entitled: bool,
#[serde(default)]
pub status: String,
#[serde(default)]
pub trial_ends_at: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct EngineInfo {
#[serde(default)]
pub engine_version: String,
#[serde(default)]
pub protocol: u32,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub mode: String,
#[serde(default)]
pub connected: bool,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub first_party_app: Option<String>,
#[serde(default)]
pub registrants: u32,
#[serde(default)]
pub billing: Option<BillingInfo>,
}
impl EngineInfo {
pub fn needs_renewal(&self) -> bool {
matches!(
self.billing.as_ref().map(|b| b.status.as_str()),
Some("hold") | Some("past_due")
)
}
}
pub fn discover(mgmt_base: &str) -> Option<EngineInfo> {
client()
.get(format!("{mgmt_base}/engine/info"))
.timeout(CALL_TIMEOUT)
.send()
.ok()?
.json::<EngineInfo>()
.ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartAction {
Attach,
Takeover,
}
pub fn decide_start_action(info: Option<&EngineInfo>, protocol: u32) -> StartAction {
match info {
Some(i) if i.protocol == protocol => StartAction::Attach,
_ => StartAction::Takeover,
}
}
pub fn register(mgmt_base: &str, app_id: &str, pid: u32) -> reqwest::Result<()> {
client()
.post(format!("{mgmt_base}/engine/register"))
.json(&serde_json::json!({ "appId": app_id, "pid": pid }))
.timeout(CALL_TIMEOUT)
.send()
.map(|_| ())
}
pub fn heartbeat(mgmt_base: &str, app_id: &str, pid: u32) {
let _ = client()
.post(format!("{mgmt_base}/engine/heartbeat"))
.json(&serde_json::json!({ "appId": app_id, "pid": pid }))
.timeout(CALL_TIMEOUT)
.send();
}
pub fn deregister(mgmt_base: &str, app_id: &str) {
let _ = client()
.post(format!("{mgmt_base}/engine/deregister"))
.json(&serde_json::json!({ "appId": app_id }))
.timeout(CALL_TIMEOUT)
.send();
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PublishResult {
#[serde(default)]
pub status: String,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
pub fn publish(
mgmt_base: &str,
name: &str,
label: &str,
local_port: u16,
app_id: Option<&str>,
) -> reqwest::Result<PublishResult> {
let mut body = serde_json::json!({ "name": name, "label": label, "localPort": local_port });
if let Some(id) = app_id {
body["appId"] = serde_json::Value::String(id.to_string());
}
client()
.post(format!("{mgmt_base}/publish"))
.json(&body)
.timeout(CALL_TIMEOUT)
.send()?
.json::<PublishResult>()
}
pub fn publish_status(mgmt_base: &str, name: &str) -> reqwest::Result<PublishResult> {
client()
.get(format!("{mgmt_base}/publish/{name}"))
.timeout(CALL_TIMEOUT)
.send()?
.json::<PublishResult>()
}
pub fn unpublish(mgmt_base: &str, name: &str) {
let _ = client()
.delete(format!("{mgmt_base}/publish/{name}"))
.timeout(CALL_TIMEOUT)
.send();
}
pub fn status(mgmt_base: &str) -> Option<serde_json::Value> {
client()
.get(format!("{mgmt_base}/status"))
.timeout(CALL_TIMEOUT)
.send()
.ok()?
.json::<serde_json::Value>()
.ok()
}
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub node_bin: PathBuf,
pub agent_path: PathBuf,
pub mode: String,
pub device_token: String,
pub relay_addr: String,
pub control_plane: String,
pub local_port: u16,
pub mgmt_secret: String,
pub frp_token: Option<String>,
pub frpc_bin: Option<PathBuf>,
pub cert_mode: Option<String>,
pub first_party_app: Option<String>,
pub engine_version: Option<String>,
pub work_dir: Option<PathBuf>,
pub mgmt_port: Option<u16>,
}
impl EngineConfig {
pub fn portal(
node_bin: PathBuf,
agent_path: PathBuf,
device_token: String,
relay_addr: String,
control_plane: String,
mgmt_secret: String,
) -> Self {
EngineConfig {
node_bin,
agent_path,
mode: "portal".into(),
device_token,
relay_addr,
control_plane,
local_port: 8443,
mgmt_secret,
frp_token: None,
frpc_bin: None,
cert_mode: None,
first_party_app: None,
engine_version: None,
work_dir: None,
mgmt_port: None,
}
}
pub fn to_args(&self) -> Vec<String> {
let mut a: Vec<String> = vec![
"--mode".into(),
self.mode.clone(),
"--device-token".into(),
self.device_token.clone(),
"--relay-addr".into(),
self.relay_addr.clone(),
"--control-plane".into(),
self.control_plane.clone(),
"--local-port".into(),
self.local_port.to_string(),
"--mgmt-secret".into(),
self.mgmt_secret.clone(),
];
if let Some(ft) = self.frp_token.as_ref().filter(|s| !s.is_empty()) {
a.push("--frp-token".into());
a.push(ft.clone());
}
if let Some(fb) = &self.frpc_bin {
a.push("--frpc-bin".into());
a.push(fb.display().to_string());
}
if let Some(cm) = &self.cert_mode {
a.push("--cert-mode".into());
a.push(cm.clone());
}
if let Some(fp) = &self.first_party_app {
a.push("--first-party-app".into());
a.push(fp.clone());
}
if let Some(ev) = &self.engine_version {
a.push("--engine-version".into());
a.push(ev.clone());
}
if let Some(wd) = &self.work_dir {
a.push("--work-dir".into());
a.push(wd.display().to_string());
}
if let Some(mp) = self.mgmt_port {
a.push("--mgmt-port".into());
a.push(mp.to_string());
}
a
}
pub fn command(&self) -> Command {
let mut c = Command::new(&self.node_bin);
c.arg(&self.agent_path);
c.args(self.to_args());
c
}
pub fn spawn(&self) -> std::io::Result<Child> {
let mut c = self.command();
c.stdout(Stdio::null()).stderr(Stdio::null());
c.spawn()
}
pub fn mgmt_base(&self) -> String {
format!("http://127.0.0.1:{}", self.mgmt_port.unwrap_or(8765))
}
}
pub enum StartOutcome {
Attached(EngineInfo),
Spawned(Child),
}
pub fn spawn_or_attach(
cfg: &EngineConfig,
app_id: &str,
pid: u32,
) -> std::io::Result<StartOutcome> {
let base = cfg.mgmt_base();
if let Some(info) = discover(&base) {
if decide_start_action(Some(&info), ENGINE_PROTOCOL) == StartAction::Attach {
let _ = register(&base, app_id, pid);
return Ok(StartOutcome::Attached(info));
}
}
Ok(StartOutcome::Spawned(cfg.spawn()?))
}
pub fn wait_engine(mgmt_base: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if discover(mgmt_base).is_some() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(200));
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceCode {
pub code: String,
pub verify_url: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Exchange {
#[serde(default)]
pub status: String, #[serde(default)]
pub device_token: Option<String>,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Debug)]
pub enum ConnectError {
Http(reqwest::Error),
Io(std::io::Error),
CodeExpired,
Timeout,
NoCredential,
EngineUnreachable,
}
impl std::fmt::Display for ConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectError::Http(e) => write!(f, "network error: {e}"),
ConnectError::Io(e) => write!(f, "spawn error: {e}"),
ConnectError::CodeExpired => write!(f, "the approval code expired — please try again"),
ConnectError::Timeout => write!(f, "timed out waiting for approval in the browser"),
ConnectError::NoCredential => write!(f, "approval returned no credential"),
ConnectError::EngineUnreachable => write!(f, "the engine did not come up in time"),
}
}
}
impl std::error::Error for ConnectError {}
impl From<reqwest::Error> for ConnectError {
fn from(e: reqwest::Error) -> Self {
ConnectError::Http(e)
}
}
impl From<std::io::Error> for ConnectError {
fn from(e: std::io::Error) -> Self {
ConnectError::Io(e)
}
}
pub fn request_device_code(control_plane: &str) -> reqwest::Result<DeviceCode> {
client()
.post(format!("{control_plane}/device/code"))
.timeout(Duration::from_secs(10))
.send()?
.json::<DeviceCode>()
}
pub fn poll_exchange(
control_plane: &str,
code: &str,
timeout: Duration,
interval: Duration,
) -> Result<Exchange, ConnectError> {
let deadline = Instant::now() + timeout;
loop {
let ex: Exchange = client()
.get(format!("{control_plane}/device/exchange?code={code}"))
.timeout(Duration::from_secs(10))
.send()?
.json()?;
match ex.status.as_str() {
"approved" => return Ok(ex),
"unknown" => return Err(ConnectError::CodeExpired),
_ => {}
}
if Instant::now() >= deadline {
return Err(ConnectError::Timeout);
}
std::thread::sleep(interval);
}
}
pub struct ConnectRequest<'a> {
pub control_plane: &'a str,
pub app_id: &'a str,
pub publish_name: &'a str,
pub publish_label: &'a str,
pub local_port: u16,
pub poll_timeout: Duration,
}
pub struct Connected {
pub device_token: String,
pub host: String,
pub publish: PublishResult,
pub attached: bool,
}
pub fn connect<O, P, B>(
req: &ConnectRequest,
pid: u32,
open_url: O,
persist: P,
build_config: B,
) -> Result<Connected, ConnectError>
where
O: FnOnce(&str),
P: FnOnce(&str, &str),
B: FnOnce(&str) -> EngineConfig,
{
let dc = request_device_code(req.control_plane)?;
open_url(&dc.verify_url);
let ex = poll_exchange(req.control_plane, &dc.code, req.poll_timeout, Duration::from_secs(2))?;
let token = ex.device_token.ok_or(ConnectError::NoCredential)?;
let host = ex.host.unwrap_or_default();
persist(&token, &host);
let cfg = build_config(&token);
let base = cfg.mgmt_base();
let outcome = spawn_or_attach(&cfg, req.app_id, pid)?;
let attached = matches!(outcome, StartOutcome::Attached(_));
if !wait_engine(&base, Duration::from_secs(30)) {
return Err(ConnectError::EngineUnreachable);
}
let publish = publish(&base, req.publish_name, req.publish_label, req.local_port, Some(req.app_id))?;
Ok(Connected { device_token: token, host, publish, attached })
}
#[cfg(test)]
mod tests {
use super::*;
fn info(protocol: u32) -> EngineInfo {
EngineInfo { protocol, ..Default::default() }
}
#[test]
fn attach_only_on_matching_protocol() {
assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL)), ENGINE_PROTOCOL), StartAction::Attach);
assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL + 1)), ENGINE_PROTOCOL), StartAction::Takeover);
assert_eq!(decide_start_action(Some(&info(0)), ENGINE_PROTOCOL), StartAction::Takeover);
}
#[test]
fn takeover_when_no_engine_answers() {
assert_eq!(decide_start_action(None, ENGINE_PROTOCOL), StartAction::Takeover);
}
#[test]
fn engine_info_parses_camelcase() {
let j = r#"{"engineVersion":"0.4.0","protocol":1,"pid":42,"mode":"portal",
"connected":true,"host":"alice.meradomo.com","name":"alice",
"firstPartyApp":"com.example.app","registrants":2}"#;
let i: EngineInfo = serde_json::from_str(j).unwrap();
assert_eq!(i.engine_version, "0.4.0");
assert_eq!(i.protocol, 1);
assert_eq!(i.pid, 42);
assert_eq!(i.connected, true);
assert_eq!(i.name.as_deref(), Some("alice"));
assert_eq!(i.first_party_app.as_deref(), Some("com.example.app"));
assert_eq!(i.registrants, 2);
}
#[test]
fn bare_config_emits_exactly_the_portal_flags() {
let cfg = EngineConfig::portal(
"node".into(),
"agent.mjs".into(),
"tok".into(),
"relay:7000".into(),
"http://cp:9002".into(),
"secret".into(),
);
let args = cfg.to_args();
assert_eq!(
args,
vec![
"--mode", "portal",
"--device-token", "tok",
"--relay-addr", "relay:7000",
"--control-plane", "http://cp:9002",
"--local-port", "8443",
"--mgmt-secret", "secret",
]
);
}
#[test]
fn optional_flags_appear_only_when_set() {
let mut cfg = EngineConfig::portal(
"node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
);
cfg.frp_token = Some("ftok".into());
cfg.frpc_bin = Some("/side/frpc".into());
cfg.cert_mode = Some("acme".into());
cfg.first_party_app = Some("com.example.app".into());
cfg.engine_version = Some("0.4.0".into());
let args = cfg.to_args();
assert!(args.windows(2).any(|w| w == ["--frp-token", "ftok"]));
assert!(args.windows(2).any(|w| w == ["--frpc-bin", "/side/frpc"]));
assert!(args.windows(2).any(|w| w == ["--cert-mode", "acme"]));
assert!(args.windows(2).any(|w| w == ["--first-party-app", "com.example.app"]));
assert!(args.windows(2).any(|w| w == ["--engine-version", "0.4.0"]));
}
#[test]
fn empty_frp_token_is_omitted() {
let mut cfg = EngineConfig::portal(
"node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
);
cfg.frp_token = Some(String::new());
assert!(!cfg.to_args().iter().any(|a| a == "--frp-token"));
}
#[test]
fn mgmt_base_reflects_port() {
let mut cfg = EngineConfig::portal(
"node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
);
assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8765");
cfg.mgmt_port = Some(8790);
assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8790");
}
#[test]
fn device_code_parses() {
let dc: DeviceCode = serde_json::from_str(
r#"{"code":"abc123","verifyUrl":"https://account.meradomo.com/device/approve?code=abc123"}"#,
)
.unwrap();
assert_eq!(dc.code, "abc123");
assert!(dc.verify_url.contains("device/approve"));
}
#[test]
fn exchange_pending_then_approved() {
let pending: Exchange = serde_json::from_str(r#"{"status":"pending"}"#).unwrap();
assert_eq!(pending.status, "pending");
assert!(pending.device_token.is_none());
let approved: Exchange = serde_json::from_str(
r#"{"status":"approved","deviceToken":"tok-xyz","host":"alice.meradomo.com"}"#,
)
.unwrap();
assert_eq!(approved.status, "approved");
assert_eq!(approved.device_token.as_deref(), Some("tok-xyz"));
assert_eq!(approved.host.as_deref(), Some("alice.meradomo.com"));
}
#[test]
fn connect_error_messages_are_human() {
assert!(ConnectError::Timeout.to_string().contains("browser"));
assert!(ConnectError::CodeExpired.to_string().contains("expired"));
assert!(ConnectError::EngineUnreachable.to_string().contains("engine"));
}
#[test]
fn needs_renewal_only_on_lapse() {
let mk = |s: &str| EngineInfo {
billing: Some(BillingInfo { status: s.into(), ..Default::default() }),
..Default::default()
};
assert!(mk("hold").needs_renewal());
assert!(mk("past_due").needs_renewal());
assert!(!mk("active").needs_renewal());
assert!(!mk("trialing").needs_renewal());
assert!(!mk("comp").needs_renewal());
assert!(!EngineInfo::default().needs_renewal());
}
#[test]
fn engine_info_parses_billing() {
let j = r#"{"protocol":1,"billing":{"entitled":false,"status":"hold","trialEndsAt":123}}"#;
let i: EngineInfo = serde_json::from_str(j).unwrap();
let b = i.billing.as_ref().unwrap();
assert_eq!(b.entitled, false);
assert_eq!(b.status, "hold");
assert_eq!(b.trial_ends_at, Some(123));
assert!(i.needs_renewal());
}
}