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:
Dinko Zdravac
2024-11-21 14:32:15 +01:00
committed by GitHub
parent aa460076f4
commit 3f072e4e9d
27 changed files with 668 additions and 234 deletions
@@ -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");
}
}
+74
View File
@@ -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(())
}
}
+140
View File
@@ -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)
}
}
}