Public release release-f6c01dca61ee
Source commit: f6c01dca61eefca1a7431c48878a04dbb7a3f710 Public tree identity: sha256:3df7a93eb61c8c5c7841ac9ad6eedec89b167fd77fdd32e9fc31b57c10207387
This commit is contained in:
commit
a2664f4345
210 changed files with 78559 additions and 0 deletions
224
crates/clusterflux-node/src/node_identity.rs
Normal file
224
crates/clusterflux-node/src/node_identity.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use clusterflux_core::{
|
||||
generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request,
|
||||
signed_request_payload_digest, NodeId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::coordinator_session::CoordinatorSession;
|
||||
use crate::daemon::Args;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct StoredNodeCredential {
|
||||
kind: String,
|
||||
node: String,
|
||||
private_key: String,
|
||||
public_key: String,
|
||||
credential_scope: String,
|
||||
}
|
||||
|
||||
pub(crate) fn establish_node_identity(
|
||||
session: &mut CoordinatorSession,
|
||||
args: &Args,
|
||||
node_private_key: &str,
|
||||
) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
let derived_public_key =
|
||||
node_ed25519_public_key_from_private_key(node_private_key).map_err(invalid_key_error)?;
|
||||
let public_key = args
|
||||
.public_key
|
||||
.clone()
|
||||
.unwrap_or(derived_public_key.clone());
|
||||
if public_key != derived_public_key {
|
||||
return Err(
|
||||
"node --public-key must match CLUSTERFLUX_NODE_PRIVATE_KEY or the stored local node credential"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let Some(grant) = &args.enrollment_grant {
|
||||
session.request(json!({
|
||||
"type": "exchange_node_enrollment_grant",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"node": &args.node,
|
||||
"public_key": public_key,
|
||||
"enrollment_grant": grant,
|
||||
}))
|
||||
} else {
|
||||
Ok(json!({
|
||||
"type": "node_identity_reused",
|
||||
"node": &args.node,
|
||||
"credential_source": "stored_project_node_identity",
|
||||
"subsequent_authentication": "signed_node_requests",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn node_private_key_for_runtime(
|
||||
node: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
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, Box<dyn std::error::Error>> {
|
||||
let file = local_node_credential_file(project, node);
|
||||
if credential_file_exists_without_symlink(&file)? {
|
||||
let bytes = std::fs::read(&file)?;
|
||||
let credential: StoredNodeCredential = serde_json::from_slice(&bytes)?;
|
||||
if credential.node != node {
|
||||
return Err(format!(
|
||||
"stored node credential {} belongs to node `{}` instead of `{}`",
|
||||
file.display(),
|
||||
credential.node,
|
||||
node
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let public_key = node_ed25519_public_key_from_private_key(&credential.private_key)
|
||||
.map_err(invalid_key_error)?;
|
||||
if public_key != credential.public_key {
|
||||
return Err(format!(
|
||||
"stored node credential {} has a public key that does not match its private key",
|
||||
file.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
return Ok(credential.private_key);
|
||||
}
|
||||
|
||||
let private_key = generate_ed25519_private_key().map_err(invalid_key_error)?;
|
||||
let public_key =
|
||||
node_ed25519_public_key_from_private_key(&private_key).map_err(invalid_key_error)?;
|
||||
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, Box<dyn std::error::Error>> {
|
||||
match std::fs::symlink_metadata(file) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => Err(format!(
|
||||
"refusing to read node credential through symbolic link {}",
|
||||
file.display()
|
||||
)
|
||||
.into()),
|
||||
Ok(metadata) if !metadata.is_file() => Err(format!(
|
||||
"node credential path {} is not a regular file",
|
||||
file.display()
|
||||
)
|
||||
.into()),
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_node_credential(
|
||||
file: &Path,
|
||||
credential: &StoredNodeCredential,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::io::Write;
|
||||
|
||||
let parent = file
|
||||
.parent()
|
||||
.ok_or_else(|| format!("node credential path {} has no parent", file.display()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
if std::fs::symlink_metadata(parent)?.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"refusing to store node credential through symbolic-link directory {}",
|
||||
parent.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
|
||||
#[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| {
|
||||
format!(
|
||||
"refusing to overwrite node credential {}: {}",
|
||||
file.display(),
|
||||
error.error
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf {
|
||||
let digest = clusterflux_core::Digest::sha256(node);
|
||||
let file_stem = digest.as_str().trim_start_matches("sha256:");
|
||||
project
|
||||
.join(".clusterflux")
|
||||
.join("nodes")
|
||||
.join(format!("{file_stem}.json"))
|
||||
}
|
||||
|
||||
pub(crate) fn node_nonce(prefix: &str) -> String {
|
||||
format!("{prefix}-{}-{}", unix_timestamp_nanos(), std::process::id())
|
||||
}
|
||||
|
||||
pub(crate) fn unix_timestamp_seconds() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn signed_node_request_json(
|
||||
args: &Args,
|
||||
node_private_key: &str,
|
||||
request_kind: &str,
|
||||
request: Value,
|
||||
) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
let payload_digest = signed_request_payload_digest(&request);
|
||||
let node_signature = sign_node_request(
|
||||
node_private_key,
|
||||
&NodeId::from(args.node.as_str()),
|
||||
request_kind,
|
||||
&payload_digest,
|
||||
node_nonce(request_kind),
|
||||
unix_timestamp_seconds(),
|
||||
)
|
||||
.map_err(invalid_key_error)?;
|
||||
Ok(json!({
|
||||
"type": "signed_node",
|
||||
"node": &args.node,
|
||||
"node_signature": node_signature,
|
||||
"request": request,
|
||||
}))
|
||||
}
|
||||
|
||||
fn invalid_key_error(error: String) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, error)
|
||||
}
|
||||
|
||||
pub(crate) fn unix_timestamp_nanos() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue