NS Agent auth with NS API (#5127)
* Agents authenticate with NSAPI * /submit with better auth - also adjust agent run script to authenticate, even in parallel * /request better authentication - moved agent API calls to Client struct * Replay protection * Fix testrun cleanup bug - introduce a new column last_assigned which is different than created_at so that stale testruns get cleaned up based on last_assigned - created_at is still useful for determining the "oldest" testrun to be picked up * Uniform request authentication * Suppress ts-rs serde warnings * Update cargo version * All agents use the same key - remove assigned_agent column - remove logic which would stop agents with the same key to connect - as a safety measure, add cap to total no. of agents
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
use std::{fs::File, io::Write, path::Path};
|
||||
use tracing::info;
|
||||
|
||||
pub(crate) fn generate_key_pair(path: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let priv_key_path = path.as_ref();
|
||||
let mut rng = rand::thread_rng();
|
||||
let keypair = nym_crypto::asymmetric::identity::KeyPair::new(&mut rng);
|
||||
info!("Generated keypair as Base58-encoded string");
|
||||
|
||||
let mut private_key_file = File::create(priv_key_path)?;
|
||||
private_key_file.write_all(keypair.private_key().to_base58_string().as_bytes())?;
|
||||
|
||||
let pub_key_path = priv_key_path.with_extension("public");
|
||||
let mut public_key_file = File::create(&pub_key_path)?;
|
||||
public_key_file.write_all(keypair.public_key().to_base58_string().as_bytes())?;
|
||||
|
||||
info!(
|
||||
"Saved Base58-encoded keypair, private key to {}, public key to {}",
|
||||
priv_key_path.display(),
|
||||
pub_key_path.display()
|
||||
);
|
||||
info!("Public key should be whitelisted with NS API");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use nym_crypto::asymmetric::ed25519::PrivateKey;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
fn can_generate_valid_keypair() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let pkey_file = PathBuf::from_iter(&[
|
||||
tmp_dir.path().to_path_buf(),
|
||||
PathBuf::from("agent-key-private"),
|
||||
]);
|
||||
generate_key_pair(&pkey_file).expect("Failed to generate keypair");
|
||||
|
||||
let pkey_raw = fs::read_to_string(&pkey_file).expect("Failed to read file");
|
||||
let key = PrivateKey::from_base58_string(pkey_raw).expect("Failed to load key");
|
||||
|
||||
let msg = "hello, world";
|
||||
|
||||
let signature = key.sign(msg);
|
||||
key.public_key()
|
||||
.verify(msg, &signature)
|
||||
.expect("Failed to verify signature");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::probe::GwProbe;
|
||||
use clap::{Parser, Subcommand};
|
||||
use nym_bin_common::bin_info;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub(crate) mod generate_keypair;
|
||||
pub(crate) mod run_probe;
|
||||
|
||||
// Helper for passing LONG_VERSION to clap
|
||||
fn pretty_build_info_static() -> &'static str {
|
||||
static PRETTY_BUILD_INFORMATION: OnceLock<String> = OnceLock::new();
|
||||
PRETTY_BUILD_INFORMATION.get_or_init(|| bin_info!().pretty_print())
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author = "Nymtech", version, long_version = pretty_build_info_static(), about)]
|
||||
pub(crate) struct Args {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum Command {
|
||||
RunProbe {
|
||||
#[arg(short, long, env = "NODE_STATUS_AGENT_SERVER_ADDRESS")]
|
||||
server_address: String,
|
||||
|
||||
#[arg(short = 'p', long, env = "NODE_STATUS_AGENT_SERVER_PORT")]
|
||||
server_port: u16,
|
||||
|
||||
/// base58-encoded private key
|
||||
#[arg(long, env = "NODE_STATUS_AGENT_AUTH_KEY")]
|
||||
ns_api_auth_key: String,
|
||||
|
||||
/// path of binary to run
|
||||
#[arg(long, env = "NODE_STATUS_AGENT_PROBE_PATH")]
|
||||
probe_path: String,
|
||||
},
|
||||
|
||||
GenerateKeypair {
|
||||
#[arg(long)]
|
||||
path: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub(crate) async fn execute(&self) -> anyhow::Result<()> {
|
||||
match &self.command {
|
||||
Command::RunProbe {
|
||||
server_address,
|
||||
server_port,
|
||||
ns_api_auth_key,
|
||||
probe_path,
|
||||
} => run_probe::run_probe(
|
||||
server_address,
|
||||
server_port.to_owned(),
|
||||
ns_api_auth_key,
|
||||
probe_path,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::error!("{err}");
|
||||
})?,
|
||||
Command::GenerateKeypair { path } => {
|
||||
let path = path
|
||||
.to_owned()
|
||||
.unwrap_or_else(|| String::from("private-key"));
|
||||
generate_keypair::generate_key_pair(path)?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use anyhow::{bail, Context};
|
||||
use nym_common_models::ns_api::{get_testrun, submit_results, TestrunAssignment};
|
||||
use nym_crypto::asymmetric::ed25519::{PrivateKey, Signature};
|
||||
use std::fmt::Display;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::cli::GwProbe;
|
||||
|
||||
const INTERNAL_TESTRUNS: &str = "internal/testruns";
|
||||
|
||||
pub(crate) async fn run_probe(
|
||||
server_ip: &str,
|
||||
server_port: u16,
|
||||
ns_api_auth_key: &str,
|
||||
probe_path: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let auth_key = PrivateKey::from_base58_string(ns_api_auth_key)
|
||||
.context("Couldn't parse auth key, exiting")?;
|
||||
let ns_api_client = Client::new(server_ip, server_port, auth_key);
|
||||
|
||||
let probe = GwProbe::new(probe_path.to_string());
|
||||
|
||||
let version = probe.version().await;
|
||||
tracing::info!("Probe version:\n{}", version);
|
||||
|
||||
if let Some(testrun) = ns_api_client.request_testrun().await? {
|
||||
let log = probe.run_and_get_log(&Some(testrun.gateway_identity_key));
|
||||
|
||||
ns_api_client
|
||||
.submit_results(testrun.testrun_id, log, testrun.assigned_at_utc)
|
||||
.await?;
|
||||
} else {
|
||||
tracing::info!("No testruns available, exiting")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Client {
|
||||
server_address: String,
|
||||
client: reqwest::Client,
|
||||
auth_key: PrivateKey,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(server_ip: &str, server_port: u16, auth_key: PrivateKey) -> Self {
|
||||
let server_address = format!("{}:{}", server_ip, server_port);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
Self {
|
||||
server_address,
|
||||
client,
|
||||
auth_key,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all)]
|
||||
pub(crate) async fn request_testrun(&self) -> anyhow::Result<Option<TestrunAssignment>> {
|
||||
let target_url = self.api_with_subpath(None::<String>);
|
||||
|
||||
let payload = get_testrun::Payload {
|
||||
agent_public_key: self.auth_key.public_key(),
|
||||
timestamp: chrono::offset::Utc::now().timestamp(),
|
||||
};
|
||||
let signature = self.sign_message(&payload)?;
|
||||
let request = get_testrun::GetTestrunRequest { payload, signature };
|
||||
|
||||
let res = self.client.get(target_url).json(&request).send().await?;
|
||||
let status = res.status();
|
||||
let response_text = res.text().await?;
|
||||
|
||||
if status.is_client_error() {
|
||||
bail!("{}: {}", status, response_text);
|
||||
} else if status.is_server_error() {
|
||||
if matches!(status, reqwest::StatusCode::SERVICE_UNAVAILABLE)
|
||||
&& response_text.contains("No testruns available")
|
||||
{
|
||||
return Ok(None);
|
||||
} else {
|
||||
bail!("{}: {}", status, response_text);
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::from_str(&response_text)
|
||||
.map(|testrun| {
|
||||
tracing::info!("Received testrun assignment: {:?}", testrun);
|
||||
testrun
|
||||
})
|
||||
.map_err(|err| {
|
||||
tracing::error!("err");
|
||||
err.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, probe_result))]
|
||||
pub(crate) async fn submit_results(
|
||||
&self,
|
||||
testrun_id: i64,
|
||||
probe_result: String,
|
||||
assigned_at_utc: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
let target_url = self.api_with_subpath(Some(testrun_id));
|
||||
|
||||
let payload = submit_results::Payload {
|
||||
probe_result,
|
||||
agent_public_key: self.auth_key.public_key(),
|
||||
assigned_at_utc,
|
||||
};
|
||||
let signature = self.sign_message(&payload)?;
|
||||
let submit_results = submit_results::SubmitResults { payload, signature };
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(target_url)
|
||||
.json(&submit_results)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?;
|
||||
|
||||
tracing::debug!("Submitted results: {})", res.status());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sign_message<T>(&self, message: &T) -> anyhow::Result<Signature>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let serialized = bincode::serialize(message)?;
|
||||
let signed = self.auth_key.sign(&serialized);
|
||||
Ok(signed)
|
||||
}
|
||||
|
||||
fn api_with_subpath(&self, subpath: Option<impl Display>) -> String {
|
||||
if let Some(subpath) = subpath {
|
||||
format!("{}/{}/{}", self.server_address, INTERNAL_TESTRUNS, subpath)
|
||||
} else {
|
||||
format!("{}/{}", self.server_address, INTERNAL_TESTRUNS)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user