deploy-agent: Rust deployment automation (server + stub + signed payloads)
- Workspace: common (protocol/crypto/tarball), server (axum+rustls), stub (registration, poll, verify-before-execute) - Ed25519 payload signing + request-signature auth on /poll, /report - flate2/miniz_oxide pure-Rust tarball, no system deps - CI matrix: linux musl x86_64/aarch64, windows x86_64 - Compiles clean: cargo build --workspace, 0 errors 0 warnings
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "stub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
thiserror = "1.0"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
base64 = "0.21"
|
||||
rand = "0.8"
|
||||
anyhow = "1.0"
|
||||
ed25519-dalek = "2.1"
|
||||
whoami = "1.5"
|
||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "time"] }
|
||||
@@ -0,0 +1,146 @@
|
||||
use crate::config::AgentConfig;
|
||||
use common::proto::{Instruction, StatusReport, SignedRequest};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use reqwest::Client;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use common::tarball;
|
||||
use std::process::Command;
|
||||
use log::*;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Build an authenticated request envelope: sign the canonical body with
|
||||
/// the machine's Ed25519 private key.
|
||||
fn signed_request(config: &AgentConfig, body: &serde_json::Value) -> anyhow::Result<SignedRequest> {
|
||||
let key_bytes: [u8; 32] = hex::decode(&config.privkey)?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("private key decode failed"))?;
|
||||
let sk = SigningKey::from_bytes(&key_bytes);
|
||||
let canonical = serde_json::to_vec(body)?;
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
^ rand::random::<u64>();
|
||||
let signature = common::crypto::sign(&canonical, &sk);
|
||||
Ok(SignedRequest {
|
||||
machine_id: config.machine_id.clone(),
|
||||
body: canonical,
|
||||
signature,
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run_agent() -> anyhow::Result<()> {
|
||||
let client = Client::builder()
|
||||
.use_rustls_tls()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let config = match AgentConfig::load() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(_) => {
|
||||
// --- Registration (one-time) ---
|
||||
let (sk, vk) = common::crypto::generate_ed25519_keypair()
|
||||
.map_err(|e| anyhow::anyhow!("keygen: {e:?}"))?;
|
||||
let machine_id = format!("{}-{:x}", whoami::fallible::hostname().unwrap_or_else(|_| "host".into()), rand::random::<u32>());
|
||||
let server_url = std::env::var("DEPLOY_AGENT_SERVER_URL")
|
||||
.unwrap_or_else(|_| "https://localhost:443".into());
|
||||
|
||||
let reg_resp = client
|
||||
.post(format!("{server_url}/register"))
|
||||
.json(&serde_json::json!({
|
||||
"machine_id": machine_id,
|
||||
"pubkey": hex::encode(vk.to_bytes()),
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
if !reg_resp.status().is_success() {
|
||||
anyhow::bail!("registration failed: {}", reg_resp.status());
|
||||
}
|
||||
let reg_json: serde_json::Value = reg_resp.json().await?;
|
||||
let server_pubkey = reg_json["server_pubkey"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let cfg = AgentConfig {
|
||||
server_url: server_url.clone(),
|
||||
machine_id: machine_id.clone(),
|
||||
privkey: hex::encode(sk.to_bytes()),
|
||||
pubkey: hex::encode(vk.to_bytes()),
|
||||
server_pubkey,
|
||||
};
|
||||
cfg.save()?;
|
||||
info!("registered as {}", machine_id);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
// --- Poll for instruction (authenticated) ---
|
||||
let poll_body = serde_json::json!({ "machine_id": config.machine_id });
|
||||
let req = signed_request(&config, &poll_body)?;
|
||||
let poll_resp = client
|
||||
.post(format!("{}/poll", config.server_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
if !poll_resp.status().is_success() {
|
||||
error!("poll failed: {}", poll_resp.status());
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
continue;
|
||||
}
|
||||
let instruction: Instruction = serde_json::from_slice(&poll_resp.bytes().await?)?;
|
||||
|
||||
match instruction {
|
||||
Instruction::FetchPayload { payload_id, signature } => {
|
||||
let id_hex = hex::encode(payload_id.0);
|
||||
// The stub only fetches payloads whose id it was told to fetch,
|
||||
// and verifies the payload signature BEFORE any extraction.
|
||||
let payload_url = format!("{}/payload/{id_hex}", config.server_url);
|
||||
let bin = client.get(&payload_url).send().await?.bytes().await?;
|
||||
let server_key: [u8; 32] = hex::decode(&config.server_pubkey)
|
||||
.map_err(|_| anyhow::anyhow!("server pubkey decode failed"))?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("server pubkey invalid length"))?;
|
||||
let server_vk = VerifyingKey::from_bytes(&server_key)
|
||||
.map_err(|_| anyhow::anyhow!("server pubkey invalid"))?;
|
||||
common::crypto::verify_signature(&bin, &signature, &server_vk)?;
|
||||
info!("payload {id_hex} signature verified");
|
||||
let payload_path = Path::new("./payload.tar.gz");
|
||||
fs::write(payload_path, &bin)?;
|
||||
tarball::extract_tar_gz(payload_path, Path::new("./deploy-temp"))?;
|
||||
}
|
||||
Instruction::RunPayload { payload_id, execution } => {
|
||||
let dir = "./deploy-temp";
|
||||
let id_hex = hex::encode(payload_id.0);
|
||||
let script = if cfg!(windows) {
|
||||
format!("{id_hex}.cmd")
|
||||
} else {
|
||||
format!("{id_hex}.sh")
|
||||
};
|
||||
let script_path = Path::new(dir).join(&script);
|
||||
let mut cmd = Command::new(&script_path);
|
||||
for a in &execution.args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
let output = cmd.output()?;
|
||||
let log = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let status = StatusReport {
|
||||
success: output.status.success(),
|
||||
log,
|
||||
payload_id: Some(payload_id),
|
||||
};
|
||||
// --- Report status (authenticated) ---
|
||||
let report_body = serde_json::to_value(&status)?;
|
||||
let req = signed_request(&config, &report_body)?;
|
||||
let _ = client
|
||||
.post(format!("{}/report", config.server_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
Instruction::ReportStatus { .. } | Instruction::NoOp => {
|
||||
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use dirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serde error: {0}")]
|
||||
Serde(#[from] toml::de::Error),
|
||||
#[error("toml ser error: {0}")]
|
||||
TomlSer(#[from] toml::ser::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
pub server_url: String,
|
||||
pub machine_id: String,
|
||||
pub privkey: String, // hex
|
||||
pub pubkey: String, // hex
|
||||
pub server_pubkey: String, // hex
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
pub fn config_path() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()).into()
|
||||
} else {
|
||||
dirs::config_dir().unwrap_or_else(|| ".".into()).join("deploy-agent/config.toml")
|
||||
}
|
||||
}
|
||||
pub fn save(&self) -> Result<(), ConfigError> {
|
||||
let raw = toml::to_string_pretty(self)?;
|
||||
let path = Self::config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&path, raw)?;
|
||||
Self::restrict_perms(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Restrict config file permissions so the private key isn't world-readable.
|
||||
#[cfg(unix)]
|
||||
fn restrict_perms(path: &std::path::Path) -> Result<(), ConfigError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_perms(_path: &std::path::Path) -> Result<(), ConfigError> {
|
||||
// Windows: APPDATA ACLs are user-scoped by default; acceptable baseline.
|
||||
Ok(())
|
||||
}
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
let raw = fs::read_to_string(Self::config_path())?;
|
||||
Ok(toml::from_str(&raw)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod config;
|
||||
mod agent;
|
||||
mod platform;
|
||||
|
||||
use agent::run_agent;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
if let Err(e) = run_agent().await {
|
||||
eprintln!("error: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Platform-appropriate script launcher. agent.rs currently runs payloads
|
||||
// inline; this is the extensible cross-OS execution primitive kept for
|
||||
// rework of RunPayload handling (see agent.rs). Not yet referenced, so
|
||||
// marked allow(dead_code) intentionally.
|
||||
#[allow(dead_code)]
|
||||
#[cfg(unix)]
|
||||
pub fn run_script(script: &str, args: &[String]) -> std::io::Result<std::process::Output> {
|
||||
use std::process::Command;
|
||||
Command::new("/bin/sh").arg(script).args(args).output()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(windows)]
|
||||
pub fn run_script(script: &str, args: &[String]) -> std::io::Result<std::process::Output> {
|
||||
use std::process::Command;
|
||||
Command::new("cmd.exe").arg("/C").arg(script).args(args).output()
|
||||
}
|
||||
Reference in New Issue
Block a user