use serde::{Deserialize, Serialize};
pub use super::auto_enrollment::AUTO_ENROLLMENT_TAILNET_CHANGED_CODE;
pub const AUTO_ENROLLMENT_NODE_KEY_CHANGED_CODE: &str = "auto_enrollment_node_key_changed";
pub const HELLO_RESPONDER_NODE_KEY_MISMATCH_CODE: &str = "hello_responder_node_key_mismatch";
pub const EE_MESH_CHANGE_GUARD_CHECK_ON_STATUS_ENV: &str = "EE_MESH_CHANGE_GUARD_CHECK_ON_STATUS";
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct BoundIdentity {
pub tailnet_id: String,
pub tailnet_display_name: Option<String>,
pub materialized_on_node_key: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CurrentIdentity {
pub tailnet_id: String,
pub tailnet_display_name: Option<String>,
pub self_node_key: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IdentityGuardVerdict {
TailnetChanged {
bound_tailnet_id: String,
bound_tailnet_display_name: Option<String>,
current_tailnet_id: String,
current_tailnet_display_name: Option<String>,
},
NodeKeyChanged {
bound_node_key: String,
current_node_key: String,
},
TailnetRenamed {
tailnet_id: String,
bound_display_name: Option<String>,
current_display_name: Option<String>,
},
NoChange,
NoBoundIdentity,
}
impl IdentityGuardVerdict {
#[must_use]
pub fn refuses_auto_enrollment(&self) -> bool {
matches!(
self,
Self::TailnetChanged { .. } | Self::NodeKeyChanged { .. }
)
}
#[must_use]
pub fn refusal_code(&self) -> Option<&'static str> {
match self {
Self::TailnetChanged { .. } => Some(AUTO_ENROLLMENT_TAILNET_CHANGED_CODE),
Self::NodeKeyChanged { .. } => Some(AUTO_ENROLLMENT_NODE_KEY_CHANGED_CODE),
_ => None,
}
}
#[must_use]
pub fn repair_command(&self, workspace_path: &str) -> Option<String> {
let workspace_arg = shell_quote_command_arg(workspace_path);
let reason_arg = shell_quote_command_arg("restored from different machine");
match self {
Self::TailnetChanged { .. } => Some(format!(
"ee mesh disable --workspace {workspace_arg} && ee mesh auto-enroll --workspace {workspace_arg}"
)),
Self::NodeKeyChanged { .. } => Some(format!(
"ee mesh disable --workspace {workspace_arg} --reason {reason_arg} && ee mesh auto-enroll --workspace {workspace_arg}"
)),
_ => None,
}
}
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::TailnetChanged { .. } => "tailnet_changed",
Self::NodeKeyChanged { .. } => "node_key_changed",
Self::TailnetRenamed { .. } => "tailnet_renamed",
Self::NoChange => "no_change",
Self::NoBoundIdentity => "no_bound_identity",
}
}
}
fn shell_quote_command_arg(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('"');
for ch in value.chars() {
if matches!(ch, '"' | '$' | '`' | '\\') {
quoted.push('\\');
}
quoted.push(ch);
}
quoted.push('"');
quoted
}
#[must_use]
pub fn evaluate_identity_guard(
bound: Option<&BoundIdentity>,
current: &CurrentIdentity,
) -> IdentityGuardVerdict {
let Some(bound) = bound else {
return IdentityGuardVerdict::NoBoundIdentity;
};
if bound.tailnet_id != current.tailnet_id {
return IdentityGuardVerdict::TailnetChanged {
bound_tailnet_id: bound.tailnet_id.clone(),
bound_tailnet_display_name: bound.tailnet_display_name.clone(),
current_tailnet_id: current.tailnet_id.clone(),
current_tailnet_display_name: current.tailnet_display_name.clone(),
};
}
if bound.materialized_on_node_key != current.self_node_key {
return IdentityGuardVerdict::NodeKeyChanged {
bound_node_key: bound.materialized_on_node_key.clone(),
current_node_key: current.self_node_key.clone(),
};
}
match (
bound.tailnet_display_name.as_deref(),
current.tailnet_display_name.as_deref(),
) {
(Some(bound_name), Some(current_name)) if bound_name != current_name => {
IdentityGuardVerdict::TailnetRenamed {
tailnet_id: bound.tailnet_id.clone(),
bound_display_name: Some(bound_name.to_owned()),
current_display_name: Some(current_name.to_owned()),
}
}
_ => IdentityGuardVerdict::NoChange,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ResponderBindVerdict {
Bind,
RefuseNodeKeyMismatch {
bound_node_key: String,
current_node_key: String,
},
BindNoBoundIdentity,
}
impl ResponderBindVerdict {
#[must_use]
pub fn refuses_bind(&self) -> bool {
matches!(self, Self::RefuseNodeKeyMismatch { .. })
}
#[must_use]
pub fn refusal_code(&self) -> Option<&'static str> {
match self {
Self::RefuseNodeKeyMismatch { .. } => Some(HELLO_RESPONDER_NODE_KEY_MISMATCH_CODE),
_ => None,
}
}
}
#[must_use]
pub fn evaluate_responder_bind(
bound: Option<&BoundIdentity>,
current_self_node_key: &str,
) -> ResponderBindVerdict {
let Some(bound) = bound else {
return ResponderBindVerdict::BindNoBoundIdentity;
};
if bound.materialized_on_node_key == current_self_node_key {
ResponderBindVerdict::Bind
} else {
ResponderBindVerdict::RefuseNodeKeyMismatch {
bound_node_key: bound.materialized_on_node_key.clone(),
current_node_key: current_self_node_key.to_owned(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bound(tailnet: &str, display: Option<&str>, node: &str) -> BoundIdentity {
BoundIdentity {
tailnet_id: tailnet.to_owned(),
tailnet_display_name: display.map(str::to_owned),
materialized_on_node_key: node.to_owned(),
}
}
fn current(tailnet: &str, display: Option<&str>, node: &str) -> CurrentIdentity {
CurrentIdentity {
tailnet_id: tailnet.to_owned(),
tailnet_display_name: display.map(str::to_owned),
self_node_key: node.to_owned(),
}
}
#[test]
fn guard_no_bound_identity_when_bound_is_none() {
let verdict = evaluate_identity_guard(None, ¤t("tn_a", None, "nk_self"));
assert_eq!(verdict, IdentityGuardVerdict::NoBoundIdentity);
assert!(!verdict.refuses_auto_enrollment());
assert_eq!(verdict.kind_str(), "no_bound_identity");
}
#[test]
fn guard_no_change_when_both_identities_match_exactly() {
let b = bound("tn_a", Some("team-a"), "nk_self");
let c = current("tn_a", Some("team-a"), "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
assert_eq!(verdict, IdentityGuardVerdict::NoChange);
assert!(!verdict.refuses_auto_enrollment());
assert_eq!(verdict.kind_str(), "no_change");
}
#[test]
fn guard_no_change_when_bound_display_name_is_none_and_current_is_some() {
let b = bound("tn_a", None, "nk_self");
let c = current("tn_a", Some("team-a"), "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
assert_eq!(verdict, IdentityGuardVerdict::NoChange);
}
#[test]
fn guard_no_change_when_bound_display_name_is_some_and_current_is_none() {
let b = bound("tn_a", Some("team-a"), "nk_self");
let c = current("tn_a", None, "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
assert_eq!(verdict, IdentityGuardVerdict::NoChange);
}
#[test]
fn guard_tailnet_changed_takes_priority_over_node_key_change() {
let b = bound("tn_old", None, "nk_old");
let c = current("tn_new", None, "nk_new");
let verdict = evaluate_identity_guard(Some(&b), &c);
assert!(matches!(
verdict,
IdentityGuardVerdict::TailnetChanged { .. }
));
assert!(verdict.refuses_auto_enrollment());
assert_eq!(
verdict.refusal_code(),
Some(AUTO_ENROLLMENT_TAILNET_CHANGED_CODE)
);
}
#[test]
fn guard_tailnet_changed_captures_both_bound_and_current_ids() {
let b = bound("tn_old", Some("old-team"), "nk_self");
let c = current("tn_new", Some("new-team"), "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
let IdentityGuardVerdict::TailnetChanged {
bound_tailnet_id,
bound_tailnet_display_name,
current_tailnet_id,
current_tailnet_display_name,
} = verdict
else {
panic!("expected TailnetChanged");
};
assert_eq!(bound_tailnet_id, "tn_old");
assert_eq!(bound_tailnet_display_name.as_deref(), Some("old-team"));
assert_eq!(current_tailnet_id, "tn_new");
assert_eq!(current_tailnet_display_name.as_deref(), Some("new-team"));
}
#[test]
fn guard_node_key_changed_when_only_node_key_differs() {
let b = bound("tn_a", None, "nk_old");
let c = current("tn_a", None, "nk_new");
let verdict = evaluate_identity_guard(Some(&b), &c);
let IdentityGuardVerdict::NodeKeyChanged {
bound_node_key,
current_node_key,
} = verdict
else {
panic!("expected NodeKeyChanged");
};
assert_eq!(bound_node_key, "nk_old");
assert_eq!(current_node_key, "nk_new");
assert_eq!(
evaluate_identity_guard(Some(&b), &c).refusal_code(),
Some(AUTO_ENROLLMENT_NODE_KEY_CHANGED_CODE)
);
}
#[test]
fn guard_tailnet_renamed_when_id_matches_but_display_name_differs() {
let b = bound("tn_a", Some("old-name"), "nk_self");
let c = current("tn_a", Some("new-name"), "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
let IdentityGuardVerdict::TailnetRenamed {
tailnet_id,
bound_display_name,
current_display_name,
} = verdict
else {
panic!("expected TailnetRenamed");
};
assert_eq!(tailnet_id, "tn_a");
assert_eq!(bound_display_name.as_deref(), Some("old-name"));
assert_eq!(current_display_name.as_deref(), Some("new-name"));
assert!(!evaluate_identity_guard(Some(&b), &c).refuses_auto_enrollment());
}
#[test]
fn guard_node_key_change_priority_when_tailnet_id_matches_but_node_key_differs() {
let b = bound("tn_a", Some("team-a"), "nk_old");
let c = current("tn_a", Some("team-a"), "nk_new");
let verdict = evaluate_identity_guard(Some(&b), &c);
assert!(matches!(
verdict,
IdentityGuardVerdict::NodeKeyChanged { .. }
));
}
#[test]
fn repair_command_for_tailnet_change_includes_disable_and_auto_enroll() {
let b = bound("tn_old", None, "nk_self");
let c = current("tn_new", None, "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
let cmd = verdict
.repair_command("/Users/me/projects/foo")
.expect("repair available");
assert!(cmd.contains("ee mesh disable --workspace \"/Users/me/projects/foo\""));
assert!(cmd.contains("ee mesh auto-enroll --workspace \"/Users/me/projects/foo\""));
assert!(cmd.contains("&&"));
}
#[test]
fn repair_command_for_node_key_change_includes_explicit_reason_flag() {
let b = bound("tn_a", None, "nk_old");
let c = current("tn_a", None, "nk_new");
let verdict = evaluate_identity_guard(Some(&b), &c);
let cmd = verdict
.repair_command("/Users/me/projects/foo")
.expect("repair available");
assert!(cmd.contains("--reason \"restored from different machine\""));
assert!(cmd.contains("ee mesh disable"));
assert!(cmd.contains("ee mesh auto-enroll"));
}
#[test]
fn repair_command_shell_quotes_workspace_path() {
let b = bound("tn_old", None, "nk_self");
let c = current("tn_new", None, "nk_self");
let verdict = evaluate_identity_guard(Some(&b), &c);
let cmd = verdict
.repair_command("/Users/me/projects/weird path/it's \"fine\"")
.expect("repair available");
assert!(cmd.contains("--workspace \"/Users/me/projects/weird path/it's \\\"fine\\\"\""));
assert!(!cmd.contains("--workspace \"/Users/me/projects/weird path/it's \"fine\""));
}
#[test]
fn repair_command_returns_none_for_non_refusal_verdicts() {
assert!(
IdentityGuardVerdict::NoChange
.repair_command("/x")
.is_none()
);
assert!(
IdentityGuardVerdict::NoBoundIdentity
.repair_command("/x")
.is_none()
);
assert!(
IdentityGuardVerdict::TailnetRenamed {
tailnet_id: "tn_a".to_owned(),
bound_display_name: Some("old".to_owned()),
current_display_name: Some("new".to_owned()),
}
.repair_command("/x")
.is_none()
);
}
#[test]
fn responder_bind_no_bound_identity_when_bound_is_none() {
let verdict = evaluate_responder_bind(None, "nk_self");
assert_eq!(verdict, ResponderBindVerdict::BindNoBoundIdentity);
assert!(!verdict.refuses_bind());
assert!(verdict.refusal_code().is_none());
}
#[test]
fn responder_bind_succeeds_when_bound_node_key_matches_current() {
let b = bound("tn_a", None, "nk_self");
let verdict = evaluate_responder_bind(Some(&b), "nk_self");
assert_eq!(verdict, ResponderBindVerdict::Bind);
assert!(!verdict.refuses_bind());
}
#[test]
fn responder_bind_refuses_on_node_key_mismatch() {
let b = bound("tn_a", None, "nk_old");
let verdict = evaluate_responder_bind(Some(&b), "nk_new");
let ResponderBindVerdict::RefuseNodeKeyMismatch {
bound_node_key,
current_node_key,
} = verdict.clone()
else {
panic!("expected RefuseNodeKeyMismatch");
};
assert_eq!(bound_node_key, "nk_old");
assert_eq!(current_node_key, "nk_new");
assert!(verdict.refuses_bind());
assert_eq!(
verdict.refusal_code(),
Some(HELLO_RESPONDER_NODE_KEY_MISMATCH_CODE)
);
}
#[test]
fn responder_bind_ignores_tailnet_id_drift_when_node_key_matches() {
let b = bound("tn_old", None, "nk_self");
let verdict = evaluate_responder_bind(Some(&b), "nk_self");
assert_eq!(verdict, ResponderBindVerdict::Bind);
}
#[test]
fn verdict_serializes_with_tagged_kind_field() {
let v = IdentityGuardVerdict::TailnetChanged {
bound_tailnet_id: "tn_old".to_owned(),
bound_tailnet_display_name: None,
current_tailnet_id: "tn_new".to_owned(),
current_tailnet_display_name: None,
};
let json = serde_json::to_string(&v).expect("serialize");
assert!(json.contains("\"kind\":\"tailnet_changed\""));
}
#[test]
fn verdict_kind_str_matches_serde_tag_for_all_variants() {
for v in [
IdentityGuardVerdict::TailnetChanged {
bound_tailnet_id: String::new(),
bound_tailnet_display_name: None,
current_tailnet_id: String::new(),
current_tailnet_display_name: None,
},
IdentityGuardVerdict::NodeKeyChanged {
bound_node_key: String::new(),
current_node_key: String::new(),
},
IdentityGuardVerdict::TailnetRenamed {
tailnet_id: String::new(),
bound_display_name: None,
current_display_name: None,
},
IdentityGuardVerdict::NoChange,
IdentityGuardVerdict::NoBoundIdentity,
] {
let json = serde_json::to_string(&v).expect("serialize");
assert!(
json.contains(&format!("\"kind\":\"{}\"", v.kind_str())),
"kind_str ({}) did not match serde tag in JSON: {}",
v.kind_str(),
json
);
}
}
}