Skip to content
Open
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
81 changes: 81 additions & 0 deletions crates/soth-cli/src/cli_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,10 +1127,91 @@ pub fn sync_client_device_id(
Ok(device_id)
}

/// Force-generate a fresh client device id, overwriting any persisted one
/// in both the config tags and the on-disk `device_id` file. Used by
/// `soth enroll --new-device-id` as the escape hatch when the cloud has
/// rejected the stale device_id (e.g. after an org migration): reusing the
/// old id would just reproduce the same 403.
pub fn regenerate_client_device_id(config: &mut SothConfig) -> Result<String> {
let device_id = format!("device-{}", Uuid::new_v4());
config
.cloud
.tags
.insert("device_id".to_string(), device_id.clone());
write_client_device_id(&device_id)?;
Ok(device_id)
}

pub fn resolved_db_path(config: &SothConfig) -> PathBuf {
expand_tilde(Path::new(config.proxy.db_path.as_str()))
}

#[cfg(test)]
mod device_id_tests {
use super::{regenerate_client_device_id, SothConfig};
use std::env;

/// Run `f` with HOME (and the Windows/soth-specific home vars) pointed at
/// a throwaway tempdir so device_id file writes stay sandboxed.
fn with_temp_home<T>(f: impl FnOnce() -> T) -> T {
let _guard = crate::commands::proxy::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let old_home = env::var_os("HOME");
let old_userprofile = env::var_os("USERPROFILE");
let old_soth_home = env::var_os("SOTH_HOME_DIR");
unsafe {
env::set_var("HOME", temp.path());
env::set_var("USERPROFILE", temp.path());
env::set_var("SOTH_HOME_DIR", temp.path().join(".soth"));
}
let result = f();
match old_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match old_userprofile {
Some(v) => unsafe { env::set_var("USERPROFILE", v) },
None => unsafe { env::remove_var("USERPROFILE") },
}
match old_soth_home {
Some(v) => unsafe { env::set_var("SOTH_HOME_DIR", v) },
None => unsafe { env::remove_var("SOTH_HOME_DIR") },
}
result
}

#[test]
fn regenerate_replaces_stale_device_id_tag() {
with_temp_home(|| {
let mut config = SothConfig::default();
config
.cloud
.tags
.insert("device_id".to_string(), "device-stale".to_string());

let fresh = regenerate_client_device_id(&mut config).expect("regenerate");

assert!(fresh.starts_with("device-"), "id has device- prefix");
assert_ne!(fresh, "device-stale", "stale id was replaced");
assert_eq!(
config.cloud.tags.get("device_id").map(String::as_str),
Some(fresh.as_str()),
"config tag reflects the fresh id"
);
});
}

#[test]
fn regenerate_yields_distinct_ids() {
with_temp_home(|| {
let mut config = SothConfig::default();
let first = regenerate_client_device_id(&mut config).expect("first");
let second = regenerate_client_device_id(&mut config).expect("second");
assert_ne!(first, second, "each regenerate produces a fresh id");
});
}
}

#[cfg(test)]
mod code_extension_config_tests {
use super::{
Expand Down
33 changes: 27 additions & 6 deletions crates/soth-cli/src/command_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,29 @@ pub struct GlobalOptions {
pub verbose: bool,
}

/// GETTING STARTED block appended to `soth --help`. Puts the one-shot
/// `soth up` bootstrap front and centre so first-time users don't have to
/// reverse-engineer the golden path from the flat subcommand list.
const AFTER_HELP: &str = "\
GETTING STARTED:
Standalone (local-only):
soth setup-ca Generate + trust the local MITM CA (one time)
soth up One-shot bootstrap: init -> CA -> start -> enable proxy
soth status Confirm the daemon is healthy

Cloud-managed (team policy + telemetry):
soth setup-ca Generate + trust the local MITM CA (one time)
soth enroll <token> Exchange an invite token for cloud credentials
soth up Bootstrap and start under cloud policy
soth status Confirm the daemon + cloud heartbeat are healthy

Already enrolled? `soth up --token <token>` folds enrollment into bootstrap.
Tear down with `soth down` (stop daemon + disable system proxy).";

#[derive(Parser)]
#[command(name = "soth")]
#[command(author, version, about, long_about = None)]
#[command(after_help = AFTER_HELP)]
pub struct Cli {
#[command(flatten)]
pub global: GlobalOptions,
Expand All @@ -31,18 +51,18 @@ pub struct Cli {

#[derive(Subcommand)]
pub enum Commands {
/// One-shot bootstrap (start here): init -> CA -> start -> enable proxy
Up(UpArgs),

/// stop + off (tear down: stop daemon and disable system proxy)
Down,

/// Start the proxy daemon
Start(StartArgs),

/// Stop the proxy daemon
Stop,

/// One-shot bootstrap: init -> CA -> start -> on
Up(UpArgs),

/// stop + off
Down,

/// Enable system proxy
On(OnArgs),

Expand Down Expand Up @@ -836,6 +856,7 @@ async fn run_up_command(args: UpArgs, global_config: Option<PathBuf>) -> anyhow:
from_stdin: false,
non_interactive: true,
machine_name: args.machine_name,
new_device_id: false,
},
effective_config.clone(),
)
Expand Down
19 changes: 18 additions & 1 deletion crates/soth-cli/src/commands/enroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ pub struct EnrollArgs {
/// Optional machine name override sent during enrollment
#[arg(long)]
pub machine_name: Option<String>,

/// Regenerate the client device id before enrolling. Use this when a
/// prior enrollment left a stale device_id that the cloud now rejects
/// (heartbeat 403 org/identity mismatch): the default enroll reuses the
/// persisted device_id, which reproduces the same rejection.
#[arg(long)]
pub new_device_id: bool,
}

pub async fn run(args: EnrollArgs, global_config: Option<PathBuf>) -> Result<()> {
Expand Down Expand Up @@ -84,7 +91,17 @@ pub async fn run(args: EnrollArgs, global_config: Option<PathBuf>) -> Result<()>
.machine_name
.clone()
.unwrap_or_else(default_machine_name);
let enrollment_device_id = cli_config::sync_client_device_id(&mut config, None)?;
// `--new-device-id` forces a brand-new identity so the exchange (and the
// heartbeats that follow) register a fresh device rather than reusing a
// stale, cloud-rejected one. Without it, sync_client_device_id reuses the
// persisted id.
let enrollment_device_id = if args.new_device_id {
let regenerated = cli_config::regenerate_client_device_id(&mut config)?;
println!("Regenerated client device ID: {regenerated}");
regenerated
} else {
cli_config::sync_client_device_id(&mut config, None)?
};
let enrollment_proxy_public_key = proxy_public_key_base64(enrollment_device_id.as_str());

let response_json = exchange_enroll_token(
Expand Down
4 changes: 2 additions & 2 deletions crates/soth-cli/src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ pub async fn run(output: PathBuf) -> Result<()> {
println!();
println!("Next steps:");
println!(" 1. soth setup-ca");
println!(" 2. soth start");
println!(" 3. soth on");
println!(" 2. soth enroll <token> (cloud-managed only; skip for standalone)");
println!(" 3. soth up (or: soth start && soth on)");
println!(" 4. soth status");
Ok(())
}
Expand Down
112 changes: 112 additions & 0 deletions crates/soth-cli/src/commands/proxy/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,29 @@ struct DoctorReport {
daemon: DaemonDiagnostics,
system_proxy: SystemProxyDiagnostics,
ca: CaDiagnostics,
cloud: CloudDiagnostics,
loopback_bindings: LoopbackBindings,
findings: Vec<DoctorFinding>,
}

#[derive(Debug, Serialize)]
struct CloudDiagnostics {
/// Whether cloud sync is enabled in config. When false the whole
/// section is informational (standalone mode heartbeats nothing).
enabled: bool,
/// The last heartbeat rejection, if the most recent heartbeat was
/// rejected by the cloud (e.g. a 403 org/identity mismatch).
#[serde(skip_serializing_if = "Option::is_none")]
heartbeat_rejection: Option<CloudRejection>,
}

#[derive(Debug, Serialize)]
struct CloudRejection {
status: u16,
message: String,
rejected_at_unix_secs: u64,
}

#[derive(Debug, Serialize)]
struct DaemonDiagnostics {
pid_file: PathDetails,
Expand Down Expand Up @@ -353,6 +372,33 @@ pub async fn run(config_path: Option<PathBuf>, json: bool) -> Result<()> {
println!("Parse error: {err}");
}

println!();
println!("CLOUD");
println!("----------------------------------------");
println!(
"Sync enabled: {}",
if report.cloud.enabled {
"yes"
} else {
"no (standalone)"
}
);
match report.cloud.heartbeat_rejection.as_ref() {
Some(rejection) => {
println!(
"Heartbeat: REJECTED (HTTP {}) — {}",
rejection.status, rejection.message
);
println!(
"Fix: re-enroll this device — `soth enroll --new-device-id <token>`"
);
}
None if report.cloud.enabled => {
println!("Heartbeat: no rejection recorded");
}
None => {}
}

println!();
println!("LOOPBACK");
println!("----------------------------------------");
Expand Down Expand Up @@ -560,11 +606,32 @@ fn build_report(config_path: Option<PathBuf>) -> DoctorReport {
details,
};

// Cloud/auth: surface the last heartbeat rejection so an org/identity
// mismatch (403) is diagnosable here, not just a silent daemon warn.
// Only meaningful when cloud sync is enabled; best-effort read.
let heartbeat_rejection = if config.cloud.enabled {
soth_sync::heartbeat_rejection::read()
.ok()
.flatten()
.map(|r| CloudRejection {
status: r.status,
message: r.message,
rejected_at_unix_secs: r.rejected_at,
})
} else {
None
};
let cloud = CloudDiagnostics {
enabled: config.cloud.enabled,
heartbeat_rejection,
};

let mut report = DoctorReport {
managed_runtime,
daemon,
system_proxy,
ca,
cloud,
loopback_bindings,
findings: Vec::new(),
};
Expand Down Expand Up @@ -647,6 +714,21 @@ fn compute_findings(report: &DoctorReport) -> Vec<DoctorFinding> {
}
}

if let Some(rejection) = report.cloud.heartbeat_rejection.as_ref() {
findings.push(DoctorFinding {
level: "error".to_string(),
code: "cloud_heartbeat_rejected".to_string(),
message: format!(
"Cloud rejected the last heartbeat (HTTP {}): {}",
rejection.status, rejection.message
),
remediation:
"Re-enroll this device with a fresh identity: `soth enroll --new-device-id <token>` \
(a 403 usually means the persisted device_id belongs to a different org)."
.to_string(),
});
}

if report.system_proxy.state_file.exists && !report.system_proxy.owner_file.exists {
findings.push(DoctorFinding {
level: "warn".to_string(),
Expand Down Expand Up @@ -1148,6 +1230,10 @@ mod tests {
os_trust_status: None,
os_trust_detail: None,
},
cloud: CloudDiagnostics {
enabled: false,
heartbeat_rejection: None,
},
loopback_bindings: LoopbackBindings {
expected_port: 8080,
local_listener_open,
Expand Down Expand Up @@ -1194,4 +1280,30 @@ mod tests {
assert!(!findings.iter().any(|f| f.code == "listener_not_open"));
assert!(!findings.iter().any(|f| f.code == "dangling_system_proxy"));
}

#[test]
fn heartbeat_rejection_produces_reenroll_finding() {
let mut report = report_for_findings(true, true);
report.cloud.enabled = true;
report.cloud.heartbeat_rejection = Some(CloudRejection {
status: 403,
message: "org mismatch".to_string(),
rejected_at_unix_secs: 1_700_000_000,
});
let findings = compute_findings(&report);
let finding = findings
.iter()
.find(|f| f.code == "cloud_heartbeat_rejected")
.expect("cloud_heartbeat_rejected finding");
assert_eq!(finding.level, "error");
assert!(finding.message.contains("403"));
assert!(finding.remediation.contains("--new-device-id"));
}

#[test]
fn no_heartbeat_rejection_produces_no_cloud_finding() {
let report = report_for_findings(true, true);
let findings = compute_findings(&report);
assert!(!findings.iter().any(|f| f.code == "cloud_heartbeat_rejected"));
}
}
Loading
Loading