Public release release-09ca780bd67a
Source commit: 09ca780bd67a00467e78139cf38466b8201b66ca Public tree identity: sha256:2f744c260b5fc2cf074ad9129f97f87f03714902e5b9fe174128afdc342ec2d0
This commit is contained in:
commit
eb1077f380
221 changed files with 81144 additions and 0 deletions
837
crates/clusterflux-cli/src/node.rs
Normal file
837
crates/clusterflux-cli/src/node.rs
Normal file
|
|
@ -0,0 +1,837 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clusterflux_core::{
|
||||
generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request,
|
||||
signed_request_payload_digest, Capability, Digest, EnvironmentBackend, NodeCapabilities,
|
||||
NodeId,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::client::{authenticated_or_local_trusted_request, JsonLineSession};
|
||||
use crate::config::{effective_scope_value, read_cli_session, StoredCliSession};
|
||||
use crate::tools::{command_available, command_nonce, unix_timestamp_seconds};
|
||||
use crate::{
|
||||
confirmation_required_report, AttachArgs, CliScopeArgs, NodeEnrollArgs, NodeListArgs,
|
||||
NodeRevokeArgs, NodeStatusArgs,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct NodeAttachPlan {
|
||||
pub(crate) node: String,
|
||||
pub(crate) coordinator: Option<String>,
|
||||
pub(crate) capabilities: NodeCapabilities,
|
||||
pub(crate) detection: NodeAttachDetectionEvidence,
|
||||
pub(crate) grant_disclosures: Vec<CapabilityGrantDisclosure>,
|
||||
pub(crate) enrollment: Option<NodeEnrollmentPlan>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub(crate) struct NodeAttachReport {
|
||||
pub(crate) command: String,
|
||||
pub(crate) node: String,
|
||||
pub(crate) plan: NodeAttachPlan,
|
||||
pub(crate) grant_disclosures: Vec<CapabilityGrantDisclosure>,
|
||||
pub(crate) boundary: NodeAttachBoundaryEvidence,
|
||||
pub(crate) coordinator_response: Value,
|
||||
pub(crate) heartbeat_response: Value,
|
||||
pub(crate) capability_response: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct NodeAttachBoundaryEvidence {
|
||||
pub(crate) cli_contacted_coordinator: bool,
|
||||
pub(crate) coordinator_address: String,
|
||||
pub(crate) used_enrollment_exchange: bool,
|
||||
pub(crate) coordinator_session_requests: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct CapabilityGrantDisclosure {
|
||||
pub(crate) capability: Capability,
|
||||
pub(crate) grant: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) risk: String,
|
||||
pub(crate) coordinator_policy_limited: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct NodeAttachDetectionEvidence {
|
||||
pub(crate) auto_detected: bool,
|
||||
pub(crate) os: clusterflux_core::Os,
|
||||
pub(crate) arch: String,
|
||||
pub(crate) command_backend: String,
|
||||
pub(crate) command_backend_available: bool,
|
||||
pub(crate) container_backend: Option<String>,
|
||||
pub(crate) container_backend_reported: bool,
|
||||
pub(crate) container_backend_available: bool,
|
||||
pub(crate) source_provider_backends: Vec<SourceProviderBackendStatus>,
|
||||
pub(crate) manual_capability_overrides_allowed: bool,
|
||||
pub(crate) manual_capability_overrides: Vec<String>,
|
||||
pub(crate) recognized_capability_overrides: Vec<Capability>,
|
||||
pub(crate) unrecognized_capability_overrides: Vec<String>,
|
||||
pub(crate) os_arch_capabilities_require_manual_flags: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct SourceProviderBackendStatus {
|
||||
pub(crate) provider: String,
|
||||
pub(crate) detected: bool,
|
||||
pub(crate) available: bool,
|
||||
pub(crate) reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct NodeEnrollmentPlan {
|
||||
pub(crate) grant: String,
|
||||
pub(crate) public_key_fingerprint: Digest,
|
||||
pub(crate) exchanges_short_lived_grant_for_long_lived_node_identity: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
struct StoredNodeCredential {
|
||||
kind: String,
|
||||
node: String,
|
||||
private_key: String,
|
||||
public_key: String,
|
||||
credential_scope: String,
|
||||
}
|
||||
|
||||
pub(crate) fn node_enroll_report(args: NodeEnrollArgs, cwd: PathBuf) -> Result<Value> {
|
||||
let stored_session = read_cli_session(&cwd)?;
|
||||
let coordinator = args.scope.coordinator.clone().or_else(|| {
|
||||
stored_session
|
||||
.as_ref()
|
||||
.map(|session| session.coordinator.clone())
|
||||
});
|
||||
if let Some(coordinator) = &coordinator {
|
||||
let tenant = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&args.scope.tenant,
|
||||
|session| session.tenant.as_str(),
|
||||
"tenant",
|
||||
);
|
||||
let project = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&args.scope.project,
|
||||
|session| session.project.as_str(),
|
||||
"project",
|
||||
);
|
||||
let user = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&args.scope.user,
|
||||
|session| session.user.as_str(),
|
||||
"user",
|
||||
);
|
||||
let ttl_seconds = args.ttl_seconds;
|
||||
let mut session = JsonLineSession::connect(coordinator)?;
|
||||
let request = authenticated_or_local_trusted_request(
|
||||
coordinator,
|
||||
stored_session.as_ref(),
|
||||
json!({
|
||||
"type": "create_node_enrollment_grant",
|
||||
"ttl_seconds": ttl_seconds,
|
||||
}),
|
||||
json!({
|
||||
"type": "create_node_enrollment_grant",
|
||||
"tenant": tenant.clone(),
|
||||
"project": project.clone(),
|
||||
"actor_user": user.clone(),
|
||||
"ttl_seconds": ttl_seconds,
|
||||
}),
|
||||
)?;
|
||||
let response = session.request(request)?;
|
||||
let enrollment_grant =
|
||||
node_enrollment_grant_summary(&response, &tenant, &project, &user, ttl_seconds)?;
|
||||
return Ok(json!({
|
||||
"command": "node enroll",
|
||||
"status": "created",
|
||||
"coordinator": coordinator,
|
||||
"tenant": tenant,
|
||||
"project": project,
|
||||
"user": user,
|
||||
"private_website_required": false,
|
||||
"enrollment_grant": enrollment_grant,
|
||||
"response": response,
|
||||
"coordinator_session_requests": session.requests(),
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"command": "node enroll",
|
||||
"status": "requires_coordinator",
|
||||
"private_website_required": false,
|
||||
"requested_ttl_seconds": args.ttl_seconds,
|
||||
"enrollment_grant": null,
|
||||
"reason": "enrollment grants are generated by the coordinator and cannot be planned client-side",
|
||||
}))
|
||||
}
|
||||
|
||||
fn node_enrollment_grant_summary(
|
||||
response: &Value,
|
||||
tenant: &str,
|
||||
project: &str,
|
||||
user: &str,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<Value> {
|
||||
let grant = response
|
||||
.get("grant")
|
||||
.and_then(Value::as_str)
|
||||
.context("coordinator did not return its generated enrollment grant")?;
|
||||
Ok(json!({
|
||||
"grant": grant,
|
||||
"tenant": response
|
||||
.get("tenant")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(tenant)),
|
||||
"project": response
|
||||
.get("project")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(project)),
|
||||
"user": user,
|
||||
"scope": response
|
||||
.get("scope")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!("node:attach")),
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"expires_at_epoch_seconds": response
|
||||
.get("expires_at_epoch_seconds")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"short_lived": true,
|
||||
"exchange_for_persistent_node_identity": true,
|
||||
"node_credentials_separate_from_user_session": true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn node_list_report(args: NodeListArgs, cwd: PathBuf) -> Result<Value> {
|
||||
node_descriptors_report("node list", args.scope, None, cwd)
|
||||
}
|
||||
|
||||
pub(crate) fn node_status_report(args: NodeStatusArgs, cwd: PathBuf) -> Result<Value> {
|
||||
node_descriptors_report("node status", args.scope, args.node, cwd)
|
||||
}
|
||||
|
||||
fn node_descriptors_report(
|
||||
command: &str,
|
||||
scope: CliScopeArgs,
|
||||
node: Option<String>,
|
||||
cwd: PathBuf,
|
||||
) -> Result<Value> {
|
||||
let stored_session = read_cli_session(&cwd)?;
|
||||
let coordinator = scope.coordinator.clone().or_else(|| {
|
||||
stored_session
|
||||
.as_ref()
|
||||
.map(|session| session.coordinator.clone())
|
||||
});
|
||||
if let Some(coordinator) = &coordinator {
|
||||
let tenant = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&scope.tenant,
|
||||
|session| session.tenant.as_str(),
|
||||
"tenant",
|
||||
);
|
||||
let project = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&scope.project,
|
||||
|session| session.project.as_str(),
|
||||
"project",
|
||||
);
|
||||
let user = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&scope.user,
|
||||
|session| session.user.as_str(),
|
||||
"user",
|
||||
);
|
||||
let mut session = JsonLineSession::connect(coordinator)?;
|
||||
let request = authenticated_or_local_trusted_request(
|
||||
coordinator,
|
||||
stored_session.as_ref(),
|
||||
json!({
|
||||
"type": "list_node_descriptors",
|
||||
}),
|
||||
json!({
|
||||
"type": "list_node_descriptors",
|
||||
"tenant": tenant.clone(),
|
||||
"project": project.clone(),
|
||||
"actor_user": user.clone(),
|
||||
}),
|
||||
)?;
|
||||
let response = session.request(request)?;
|
||||
return Ok(json!({
|
||||
"command": command,
|
||||
"coordinator": coordinator,
|
||||
"node": node,
|
||||
"response": response,
|
||||
"coordinator_session_requests": session.requests(),
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"command": command,
|
||||
"status": "local_capability_snapshot",
|
||||
"node": node.unwrap_or_else(default_node_id),
|
||||
"capabilities": NodeCapabilities::detect_current(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn node_revoke_report(args: NodeRevokeArgs, cwd: PathBuf) -> Result<Value> {
|
||||
if !args.yes {
|
||||
return Ok(confirmation_required_report(
|
||||
"node revoke",
|
||||
"revoke_node_credential",
|
||||
json!({
|
||||
"coordinator": args.scope.coordinator,
|
||||
"tenant": args.scope.tenant,
|
||||
"project": args.scope.project,
|
||||
"user": args.scope.user,
|
||||
"node": args.node,
|
||||
}),
|
||||
format!("clusterflux node revoke --node {} --yes", args.node),
|
||||
));
|
||||
}
|
||||
let stored_session = read_cli_session(&cwd)?;
|
||||
let coordinator = args.scope.coordinator.clone().or_else(|| {
|
||||
stored_session
|
||||
.as_ref()
|
||||
.map(|session| session.coordinator.clone())
|
||||
});
|
||||
if let Some(coordinator) = &coordinator {
|
||||
let tenant = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&args.scope.tenant,
|
||||
|session| session.tenant.as_str(),
|
||||
"tenant",
|
||||
);
|
||||
let project = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&args.scope.project,
|
||||
|session| session.project.as_str(),
|
||||
"project",
|
||||
);
|
||||
let user = session_or_effective_scope_value(
|
||||
stored_session.as_ref(),
|
||||
&args.scope.user,
|
||||
|session| session.user.as_str(),
|
||||
"user",
|
||||
);
|
||||
let node = args.node.clone();
|
||||
let mut session = JsonLineSession::connect(coordinator)?;
|
||||
let request = authenticated_or_local_trusted_request(
|
||||
coordinator,
|
||||
stored_session.as_ref(),
|
||||
json!({
|
||||
"type": "revoke_node_credential",
|
||||
"node": node.clone(),
|
||||
}),
|
||||
json!({
|
||||
"type": "revoke_node_credential",
|
||||
"tenant": tenant.clone(),
|
||||
"project": project.clone(),
|
||||
"actor_user": user.clone(),
|
||||
"node": node.clone(),
|
||||
}),
|
||||
)?;
|
||||
let response = session.request(request)?;
|
||||
return Ok(json!({
|
||||
"command": "node revoke",
|
||||
"coordinator": coordinator,
|
||||
"requires_confirmation": !args.yes,
|
||||
"tenant": tenant,
|
||||
"project": project,
|
||||
"user": user,
|
||||
"node": node,
|
||||
"credential_revoked": response.get("type").and_then(Value::as_str) == Some("node_credential_revoked"),
|
||||
"descriptor_removed": response
|
||||
.get("descriptor_removed")
|
||||
.cloned()
|
||||
.unwrap_or(json!(false)),
|
||||
"queued_assignments_removed": response
|
||||
.get("queued_assignments_removed")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(0)),
|
||||
"node_credentials_separate_from_user_session": true,
|
||||
"response": response,
|
||||
"coordinator_session_requests": session.requests(),
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"command": "node revoke",
|
||||
"status": "requires_coordinator",
|
||||
"requires_confirmation": !args.yes,
|
||||
"node": args.node,
|
||||
}))
|
||||
}
|
||||
|
||||
fn session_or_effective_scope_value(
|
||||
stored_session: Option<&StoredCliSession>,
|
||||
cli_value: &str,
|
||||
session_value: impl FnOnce(&StoredCliSession) -> &str,
|
||||
default_value: &str,
|
||||
) -> String {
|
||||
if let Some(session) = stored_session.filter(|session| session.session_secret.is_some()) {
|
||||
session_value(session).to_owned()
|
||||
} else {
|
||||
effective_scope_value(cli_value, stored_session.map(session_value), default_value)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_plan(args: AttachArgs) -> NodeAttachPlan {
|
||||
let mut capabilities = NodeCapabilities::detect_current();
|
||||
let mut recognized_capability_overrides = Vec::new();
|
||||
let mut unrecognized_capability_overrides = Vec::new();
|
||||
for cap in &args.caps {
|
||||
if let Some(parsed) = parse_capability(cap) {
|
||||
recognized_capability_overrides.push(parsed.clone());
|
||||
capabilities.capabilities.insert(parsed);
|
||||
} else {
|
||||
unrecognized_capability_overrides.push(cap.clone());
|
||||
}
|
||||
}
|
||||
recognized_capability_overrides.sort();
|
||||
recognized_capability_overrides.dedup();
|
||||
let node = args.node.unwrap_or_else(default_node_id);
|
||||
let public_key = args
|
||||
.public_key
|
||||
.unwrap_or_else(|| default_node_public_key_for_plan(&node));
|
||||
let enrollment = args.enrollment_grant.map(|grant| NodeEnrollmentPlan {
|
||||
grant,
|
||||
public_key_fingerprint: Digest::sha256(public_key),
|
||||
exchanges_short_lived_grant_for_long_lived_node_identity: true,
|
||||
});
|
||||
let detection = node_attach_detection_evidence(
|
||||
&capabilities,
|
||||
args.caps,
|
||||
recognized_capability_overrides,
|
||||
unrecognized_capability_overrides,
|
||||
);
|
||||
let grant_disclosures = capability_grant_disclosures(&capabilities);
|
||||
|
||||
NodeAttachPlan {
|
||||
node,
|
||||
coordinator: args.coordinator,
|
||||
capabilities,
|
||||
detection,
|
||||
grant_disclosures,
|
||||
enrollment,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_attach_detection_evidence(
|
||||
capabilities: &NodeCapabilities,
|
||||
manual_capability_overrides: Vec<String>,
|
||||
recognized_capability_overrides: Vec<Capability>,
|
||||
unrecognized_capability_overrides: Vec<String>,
|
||||
) -> NodeAttachDetectionEvidence {
|
||||
let command_backend_available = capabilities.capabilities.contains(&Capability::Command);
|
||||
let container_backend_reported = capabilities
|
||||
.environment_backends
|
||||
.contains(&EnvironmentBackend::Container);
|
||||
let container_backend = container_backend_reported.then(|| "rootless-podman".to_owned());
|
||||
let container_backend_available = container_backend_reported
|
||||
&& capabilities
|
||||
.capabilities
|
||||
.contains(&Capability::RootlessPodman)
|
||||
&& command_available("podman");
|
||||
|
||||
NodeAttachDetectionEvidence {
|
||||
auto_detected: true,
|
||||
os: capabilities.os.clone(),
|
||||
arch: capabilities.arch.clone(),
|
||||
command_backend: if command_backend_available {
|
||||
"native-command".to_owned()
|
||||
} else {
|
||||
"unavailable".to_owned()
|
||||
},
|
||||
command_backend_available,
|
||||
container_backend,
|
||||
container_backend_reported,
|
||||
container_backend_available,
|
||||
source_provider_backends: source_provider_backend_statuses(capabilities),
|
||||
manual_capability_overrides_allowed: true,
|
||||
manual_capability_overrides,
|
||||
recognized_capability_overrides,
|
||||
unrecognized_capability_overrides,
|
||||
os_arch_capabilities_require_manual_flags: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_provider_backend_statuses(
|
||||
capabilities: &NodeCapabilities,
|
||||
) -> Vec<SourceProviderBackendStatus> {
|
||||
let mut statuses = BTreeMap::new();
|
||||
for provider in &capabilities.source_providers {
|
||||
let available = match provider.as_str() {
|
||||
"filesystem" => capabilities
|
||||
.capabilities
|
||||
.contains(&Capability::SourceFilesystem),
|
||||
"git" => command_available("git"),
|
||||
_ => true,
|
||||
};
|
||||
statuses.insert(
|
||||
provider.clone(),
|
||||
SourceProviderBackendStatus {
|
||||
provider: provider.clone(),
|
||||
detected: true,
|
||||
available,
|
||||
reason: if available {
|
||||
"detected by local node capability probe".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"source provider `{provider}` was detected but its local helper is missing"
|
||||
)
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
statuses.into_values().collect()
|
||||
}
|
||||
|
||||
fn capability_grant_disclosures(capabilities: &NodeCapabilities) -> Vec<CapabilityGrantDisclosure> {
|
||||
let mut disclosures = Vec::new();
|
||||
let mut push = |capability: Capability, grant: &str, description: &str, risk: &str| {
|
||||
if capabilities.capabilities.contains(&capability) {
|
||||
disclosures.push(CapabilityGrantDisclosure {
|
||||
capability,
|
||||
grant: grant.to_owned(),
|
||||
description: description.to_owned(),
|
||||
risk: risk.to_owned(),
|
||||
coordinator_policy_limited: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
push(
|
||||
Capability::Command,
|
||||
"native_command_execution",
|
||||
"placed tasks may run native commands on this node",
|
||||
"local process execution under the node account",
|
||||
);
|
||||
push(
|
||||
Capability::WindowsCommandDev,
|
||||
"native_command_execution",
|
||||
"placed tasks may run Windows developer commands on this node",
|
||||
"local process execution under the node account",
|
||||
);
|
||||
push(
|
||||
Capability::SourceFilesystem,
|
||||
"source_access",
|
||||
"placed tasks may read the local project/source checkout exposed by this node",
|
||||
"broad local source visibility",
|
||||
);
|
||||
push(
|
||||
Capability::SourceGit,
|
||||
"source_access",
|
||||
"placed tasks may use Git-backed source access exposed by this node",
|
||||
"source-provider visibility",
|
||||
);
|
||||
push(
|
||||
Capability::Network,
|
||||
"network_access",
|
||||
"placed tasks may use outbound network access from this node",
|
||||
"network egress from the node environment",
|
||||
);
|
||||
push(
|
||||
Capability::HostFilesystem,
|
||||
"host_filesystem_access",
|
||||
"placed tasks may access configured host filesystem mounts",
|
||||
"host file visibility outside the project checkout",
|
||||
);
|
||||
push(
|
||||
Capability::Secrets,
|
||||
"secret_access",
|
||||
"placed tasks may receive configured secret material",
|
||||
"secret exposure to authorized task code",
|
||||
);
|
||||
push(
|
||||
Capability::InboundPorts,
|
||||
"inbound_ports",
|
||||
"placed tasks may expose inbound ports from this node",
|
||||
"network service exposure from the node environment",
|
||||
);
|
||||
push(
|
||||
Capability::ArbitrarySyscalls,
|
||||
"arbitrary_syscalls",
|
||||
"placed tasks may use broader host syscall surface",
|
||||
"reduced host isolation",
|
||||
);
|
||||
|
||||
disclosures.sort_by(|left, right| {
|
||||
left.grant
|
||||
.cmp(&right.grant)
|
||||
.then_with(|| left.capability.cmp(&right.capability))
|
||||
});
|
||||
disclosures
|
||||
}
|
||||
|
||||
pub(crate) fn execute_node_attach(args: AttachArgs) -> Result<NodeAttachReport> {
|
||||
let coordinator = args
|
||||
.coordinator
|
||||
.clone()
|
||||
.context("node attach execution requires --coordinator")?;
|
||||
let tenant = args.tenant.clone();
|
||||
let project = args.project.clone();
|
||||
let node = args.node.clone().unwrap_or_else(default_node_id);
|
||||
let node_private_key = node_private_key_for_attach(&node)?;
|
||||
let derived_public_key =
|
||||
node_ed25519_public_key_from_private_key(&node_private_key).map_err(anyhow::Error::msg)?;
|
||||
let public_key = args
|
||||
.public_key
|
||||
.clone()
|
||||
.unwrap_or(derived_public_key.clone());
|
||||
if public_key != derived_public_key {
|
||||
anyhow::bail!(
|
||||
"node attach --public-key must match CLUSTERFLUX_NODE_PRIVATE_KEY or the stored local node credential"
|
||||
);
|
||||
}
|
||||
let mut plan = attach_plan(args);
|
||||
if let Some(enrollment) = &mut plan.enrollment {
|
||||
enrollment.public_key_fingerprint = Digest::sha256(&public_key);
|
||||
}
|
||||
|
||||
let mut session = JsonLineSession::connect(&coordinator)?;
|
||||
let used_enrollment_exchange = plan.enrollment.is_some();
|
||||
let coordinator_response = if let Some(enrollment) = &plan.enrollment {
|
||||
session.request(json!({
|
||||
"type": "exchange_node_enrollment_grant",
|
||||
"tenant": &tenant,
|
||||
"project": &project,
|
||||
"node": &node,
|
||||
"public_key": &public_key,
|
||||
"enrollment_grant": enrollment.grant,
|
||||
}))?
|
||||
} else {
|
||||
session.request(json!({
|
||||
"type": "attach_node",
|
||||
"tenant": &tenant,
|
||||
"project": &project,
|
||||
"node": &node,
|
||||
"public_key": &public_key,
|
||||
}))?
|
||||
};
|
||||
let heartbeat_request = json!({
|
||||
"type": "node_heartbeat",
|
||||
"node": &plan.node,
|
||||
});
|
||||
let heartbeat_signature = sign_node_request(
|
||||
&node_private_key,
|
||||
&NodeId::from(plan.node.as_str()),
|
||||
"node_heartbeat",
|
||||
&signed_request_payload_digest(&heartbeat_request),
|
||||
command_nonce("node-heartbeat"),
|
||||
unix_timestamp_seconds(),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let mut heartbeat_request = heartbeat_request;
|
||||
heartbeat_request["node_signature"] = json!(heartbeat_signature);
|
||||
let heartbeat_response = session.request(heartbeat_request)?;
|
||||
let capability_response = session.request(signed_node_request_json(
|
||||
&node_private_key,
|
||||
&plan.node,
|
||||
"report_node_capabilities",
|
||||
json!({
|
||||
"type": "report_node_capabilities",
|
||||
"tenant": &tenant,
|
||||
"project": &project,
|
||||
"node": &plan.node,
|
||||
"capabilities": &plan.capabilities,
|
||||
"cached_environment_digests": [],
|
||||
"dependency_cache_digests": [],
|
||||
"source_snapshots": [],
|
||||
"artifact_locations": [],
|
||||
"direct_connectivity": true,
|
||||
"online": false,
|
||||
}),
|
||||
)?)?;
|
||||
|
||||
Ok(NodeAttachReport {
|
||||
command: "node attach".to_owned(),
|
||||
node: plan.node.clone(),
|
||||
grant_disclosures: plan.grant_disclosures.clone(),
|
||||
plan,
|
||||
boundary: NodeAttachBoundaryEvidence {
|
||||
cli_contacted_coordinator: true,
|
||||
coordinator_address: coordinator,
|
||||
used_enrollment_exchange,
|
||||
coordinator_session_requests: session.requests(),
|
||||
},
|
||||
coordinator_response,
|
||||
heartbeat_response,
|
||||
capability_response,
|
||||
})
|
||||
}
|
||||
|
||||
fn node_private_key_for_attach(node: &str) -> Result<String> {
|
||||
if let Ok(private_key) = std::env::var("CLUSTERFLUX_NODE_PRIVATE_KEY") {
|
||||
return Ok(private_key);
|
||||
}
|
||||
load_or_create_local_node_credential(&std::env::current_dir()?, node)
|
||||
}
|
||||
|
||||
pub(crate) fn load_or_create_local_node_credential(project: &Path, node: &str) -> Result<String> {
|
||||
let file = local_node_credential_file(project, node);
|
||||
if credential_file_exists_without_symlink(&file)? {
|
||||
let bytes =
|
||||
std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let credential: StoredNodeCredential = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("failed to parse {}", file.display()))?;
|
||||
if credential.node != node {
|
||||
anyhow::bail!(
|
||||
"stored node credential {} belongs to node `{}` instead of `{}`",
|
||||
file.display(),
|
||||
credential.node,
|
||||
node
|
||||
);
|
||||
}
|
||||
let public_key = node_ed25519_public_key_from_private_key(&credential.private_key)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if public_key != credential.public_key {
|
||||
anyhow::bail!(
|
||||
"stored node credential {} has a public key that does not match its private key",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
return Ok(credential.private_key);
|
||||
}
|
||||
|
||||
let private_key = generate_ed25519_private_key().map_err(anyhow::Error::msg)?;
|
||||
let public_key =
|
||||
node_ed25519_public_key_from_private_key(&private_key).map_err(anyhow::Error::msg)?;
|
||||
let credential = StoredNodeCredential {
|
||||
kind: "clusterflux_node_credential".to_owned(),
|
||||
node: node.to_owned(),
|
||||
private_key: private_key.clone(),
|
||||
public_key,
|
||||
credential_scope: "local_project_node_identity".to_owned(),
|
||||
};
|
||||
persist_node_credential(&file, &credential)?;
|
||||
Ok(private_key)
|
||||
}
|
||||
|
||||
fn credential_file_exists_without_symlink(file: &Path) -> Result<bool> {
|
||||
match std::fs::symlink_metadata(file) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
|
||||
"refusing to read node credential through symbolic link {}",
|
||||
file.display()
|
||||
),
|
||||
Ok(metadata) if !metadata.is_file() => anyhow::bail!(
|
||||
"node credential path {} is not a regular file",
|
||||
file.display()
|
||||
),
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error).with_context(|| format!("failed to inspect {}", file.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_node_credential(file: &Path, credential: &StoredNodeCredential) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let parent = file
|
||||
.parent()
|
||||
.with_context(|| format!("node credential path {} has no parent", file.display()))?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
if std::fs::symlink_metadata(parent)?.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"refusing to store node credential through symbolic-link directory {}",
|
||||
parent.display()
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
|
||||
.with_context(|| format!("failed to secure {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create temporary credential in {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
temporary
|
||||
.as_file()
|
||||
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
temporary.write_all(&serde_json::to_vec_pretty(credential)?)?;
|
||||
temporary.as_file().sync_all()?;
|
||||
temporary.persist_noclobber(file).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"refusing to overwrite node credential {}: {}",
|
||||
file.display(),
|
||||
error.error
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf {
|
||||
let digest = Digest::sha256(node);
|
||||
let file_stem = digest.as_str().trim_start_matches("sha256:");
|
||||
project
|
||||
.join(".clusterflux")
|
||||
.join("nodes")
|
||||
.join(format!("{file_stem}.json"))
|
||||
}
|
||||
|
||||
fn default_node_public_key_for_plan(node: &str) -> String {
|
||||
let private_key = generate_ed25519_private_key()
|
||||
.unwrap_or_else(|_| format!("unavailable-random-node-plan-key:{node}"));
|
||||
node_ed25519_public_key_from_private_key(&private_key)
|
||||
.unwrap_or_else(|_| format!("{node}-public-key"))
|
||||
}
|
||||
|
||||
fn signed_node_request_json(
|
||||
node_private_key: &str,
|
||||
node: &str,
|
||||
request_kind: &str,
|
||||
request: Value,
|
||||
) -> Result<Value> {
|
||||
let payload_digest = signed_request_payload_digest(&request);
|
||||
let node_signature = sign_node_request(
|
||||
node_private_key,
|
||||
&NodeId::from(node),
|
||||
request_kind,
|
||||
&payload_digest,
|
||||
command_nonce(request_kind),
|
||||
unix_timestamp_seconds(),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(json!({
|
||||
"type": "signed_node",
|
||||
"node": node,
|
||||
"node_signature": node_signature,
|
||||
"request": request,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn default_node_id() -> String {
|
||||
std::env::var("CLUSTERFLUX_NODE_ID")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| "node-local".to_owned())
|
||||
}
|
||||
|
||||
fn parse_capability(cap: &str) -> Option<Capability> {
|
||||
match cap {
|
||||
"command" => Some(Capability::Command),
|
||||
"containers" => Some(Capability::Containers),
|
||||
"rootless-podman" => Some(Capability::RootlessPodman),
|
||||
"source-filesystem" => Some(Capability::SourceFilesystem),
|
||||
"source-git" => Some(Capability::SourceGit),
|
||||
"host-filesystem" => Some(Capability::HostFilesystem),
|
||||
"network" => Some(Capability::Network),
|
||||
"secrets" => Some(Capability::Secrets),
|
||||
"inbound-ports" => Some(Capability::InboundPorts),
|
||||
"arbitrary-syscalls" => Some(Capability::ArbitrarySyscalls),
|
||||
"vfs-artifacts" => Some(Capability::VfsArtifacts),
|
||||
"windows-command-dev" => Some(Capability::WindowsCommandDev),
|
||||
"quic-direct" => Some(Capability::QuicDirect),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue