deploy-agent: Rust deployment automation (server + stub + signed payloads)
build / build-windows-x86_64 (push) Has been cancelled
build / build-linux-x86_64 (push) Failing after 1m13s
build / build-linux-aarch64 (push) Failing after 1m8s

- 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:
2026-07-30 23:57:25 -07:00
commit e96d27f35c
22 changed files with 3447 additions and 0 deletions
+23
View File
@@ -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"] }
+146
View File
@@ -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;
}
}
}
}
+62
View File
@@ -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)?)
}
}
+14
View File
@@ -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);
}
}
+17
View File
@@ -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()
}