use indicatrix_net::enroll::ClaimError;
use std::path::{Path, PathBuf};
const BUNDLES_DIR_NAME: &str = "worker-certs";
#[must_use]
pub fn bundle_dir_for(settings_dir: &Path, worker_name: &str) -> PathBuf {
settings_dir
.join(BUNDLES_DIR_NAME)
.join(slugify(worker_name))
}
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_was_dash = true; for c in name.trim().chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash {
out.push('-');
last_was_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
"worker".to_string()
} else {
out
}
}
#[must_use]
pub fn claim_error_message(err: &ClaimError) -> String {
match err {
ClaimError::InvalidToken(e) => format!("That doesn't look like a valid token: {e}"),
ClaimError::InvalidAddr(msg) => format!("Enrollment address is invalid: {msg}"),
ClaimError::Connect { addr, source } => format!(
"Could not reach {addr}: {source}. Check the address and that the worker's \
`serve` process is running."
),
ClaimError::Handshake { addr, source } => {
format!("Could not establish a secure connection to {addr}: {source}.")
}
ClaimError::CaFingerprintMismatch { addr } => format!(
"Security warning: {addr} did not present the certificate authority this token was \
issued for. This is not an ordinary connection problem -- it means either the \
address is wrong, or something between you and the worker is impersonating it. \
Do not retry against a different address without confirming it with whoever gave \
you this token."
),
ClaimError::Protocol(msg) => {
format!("The connection was interrupted while redeeming the token: {msg}")
}
ClaimError::Refused => "This token was not accepted -- it may be mistyped, already \
used, or expired (tokens are single-use and expire 180 seconds after being \
issued). Ask for a fresh one and try again."
.to_string(),
ClaimError::UnexpectedResponse => {
"The worker responded unexpectedly -- it may be running an incompatible version \
of indicatrix-worker."
.to_string()
}
}
}
pub fn claim_and_write_bundle(
token: &str,
addr: &str,
bundle_dir: &Path,
) -> Result<PathBuf, String> {
let bundle = indicatrix_net::enroll::claim(token, addr).map_err(|e| claim_error_message(&e))?;
std::fs::create_dir_all(bundle_dir)
.map_err(|e| format!("could not create {}: {e}", bundle_dir.display()))?;
let ca_path = bundle_dir.join("ca.pem");
let cert_path = bundle_dir.join("client.pem");
let key_path = bundle_dir.join("client.key");
std::fs::write(&ca_path, &bundle.ca_pem)
.map_err(|e| format!("could not write {}: {e}", ca_path.display()))?;
std::fs::write(&cert_path, &bundle.client_cert_pem)
.map_err(|e| format!("could not write {}: {e}", cert_path.display()))?;
indicatrix_net::tls::write_private_key_pem(&key_path, &bundle.client_key_pem)?;
Ok(bundle_dir.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_dir_for_slugifies_the_worker_name_under_a_fixed_subdirectory() {
let settings_dir = Path::new("/home/user/.config/indicatrix-cut");
assert_eq!(
bundle_dir_for(settings_dir, "Office Workstation"),
settings_dir.join("worker-certs").join("office-workstation")
);
}
#[test]
fn bundle_dir_for_falls_back_to_worker_for_an_empty_or_punctuation_only_name() {
let settings_dir = Path::new("C:/Users/me/AppData/Roaming/indicatrix-cut");
assert_eq!(
bundle_dir_for(settings_dir, ""),
settings_dir.join("worker-certs").join("worker")
);
assert_eq!(
bundle_dir_for(settings_dir, " --- "),
settings_dir.join("worker-certs").join("worker")
);
}
#[test]
fn slugify_collapses_runs_of_punctuation_and_trims_edges() {
assert_eq!(slugify("My Laptop!!"), "my-laptop");
assert_eq!(slugify("--leading and trailing--"), "leading-and-trailing");
assert_eq!(slugify("C++ Rig #2"), "c-rig-2");
}
#[test]
fn slugify_is_stable_for_an_already_clean_name() {
assert_eq!(slugify("laptop-2"), "laptop-2");
}
#[test]
fn claim_error_message_gives_distinguishable_wording_for_each_failure_mode() {
let addr = "worker.example:7879".to_string();
let messages = [
claim_error_message(&ClaimError::InvalidToken(
indicatrix_net::token::decode("nope").unwrap_err(),
)),
claim_error_message(&ClaimError::InvalidAddr("bad address".to_string())),
claim_error_message(&ClaimError::Connect {
addr: addr.clone(),
source: std::io::Error::other("connection refused"),
}),
claim_error_message(&ClaimError::Handshake {
addr: addr.clone(),
source: std::io::Error::other("boom"),
}),
claim_error_message(&ClaimError::CaFingerprintMismatch { addr }),
claim_error_message(&ClaimError::Protocol("short read".to_string())),
claim_error_message(&ClaimError::Refused),
claim_error_message(&ClaimError::UnexpectedResponse),
];
for i in 0..messages.len() {
for j in (i + 1)..messages.len() {
assert_ne!(
messages[i], messages[j],
"messages {i} and {j} are identical"
);
}
}
}
#[test]
fn claim_error_message_flags_the_ca_mismatch_as_security_relevant_and_distinct_from_network_errors()
{
let mismatch = claim_error_message(&ClaimError::CaFingerprintMismatch {
addr: "worker.example:7879".to_string(),
});
let unreachable = claim_error_message(&ClaimError::Connect {
addr: "worker.example:7879".to_string(),
source: std::io::Error::other("connection refused"),
});
assert!(
mismatch.to_lowercase().contains("security"),
"the CA-fingerprint mismatch must not read like an ordinary network error: {mismatch}"
);
assert!(
!unreachable.to_lowercase().contains("security"),
"{unreachable}"
);
}
#[test]
fn claim_error_message_for_refused_names_expiry_and_reuse_without_claiming_to_distinguish_them()
{
let msg = claim_error_message(&ClaimError::Refused).to_lowercase();
assert!(msg.contains("expired"), "{msg}");
assert!(msg.contains("used"), "{msg}");
}
}