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 = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
axum-server = { version = "0.7", features = ["tls-rustls"] }
tokio = { version = "1.37", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
common = { path = "../common" }
thiserror = "1.0"
toml = "0.8"
sha2 = "0.10"
ed25519-dalek = "2.1"
hex = "0.4"
rand = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rustls = "0.23"
rustls-pemfile = "2.0"
parking_lot = "0.12"
+92
View File
@@ -0,0 +1,92 @@
use axum::extract::{Path, State};
use axum::{Json, http::StatusCode};
use common::crypto::verify_signature;
use common::proto::{Instruction, SignedRequest};
use ed25519_dalek::VerifyingKey;
use serde::Deserialize;
use serde_json::Value;
use crate::db::{AppState};
#[derive(Deserialize)]
pub struct RegisterReq {
machine_id: String,
pubkey: String, // hex-encoded Ed25519 verifying key
}
/// Decode a hex pubkey and parse it into an Ed25519 verifying key.
fn parse_pubkey(hex_str: &str) -> Option<VerifyingKey> {
let bytes = hex::decode(hex_str).ok()?;
let arr: [u8; 32] = bytes.try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
/// POST /register — initial (unauthenticated) machine registration.
/// Returns the server's Ed25519 public key so the stub can verify payloads.
pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterReq>,
) -> Result<Json<Value>, StatusCode> {
let _vk = parse_pubkey(&payload.pubkey).ok_or(StatusCode::BAD_REQUEST)?;
// The server does NOT trust the pubkey blindly for auth purposes — it
// stores it and will verify signed requests against it. Registration is
// tied to a one-time enrollment token in production (see README).
state.db.set_pubkey(payload.machine_id.clone(), payload.pubkey.clone());
let server_pubkey = state.db.get_server_pubkey();
Ok(Json(serde_json::json!({
"status": "ok",
"server_pubkey": server_pubkey,
// echoes the pubkey the server recorded for this machine
"registered_pubkey": payload.pubkey,
})))
}
/// Verify an authenticated request envelope against the machine's registered key.
fn verify_request(state: &AppState, req: &SignedRequest) -> Result<VerifyingKey, StatusCode> {
let pubkey_hex = state
.db
.get_pubkey(&req.machine_id)
.ok_or(StatusCode::UNAUTHORIZED)?;
let vk = parse_pubkey(&pubkey_hex).ok_or(StatusCode::UNAUTHORIZED)?;
verify_signature(&req.body, &req.signature, &vk).map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(vk)
}
/// POST /poll — authenticated stub polls for its instruction.
pub async fn poll(
State(state): State<AppState>,
Json(req): Json<SignedRequest>,
) -> Result<Json<Instruction>, StatusCode> {
verify_request(&state, &req)?;
let body: Value = serde_json::from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?;
let machine_id = body["machine_id"].as_str().ok_or(StatusCode::BAD_REQUEST)?;
Ok(Json(state.db.get_instruction(machine_id)))
}
/// POST /report — authenticated stub reports execution status.
pub async fn report(
State(state): State<AppState>,
Json(req): Json<SignedRequest>,
) -> Result<StatusCode, StatusCode> {
verify_request(&state, &req)?;
let status: common::proto::StatusReport =
serde_json::from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?;
state.db.record_report(status);
Ok(StatusCode::OK)
}
/// GET /payload/{id} — download a payload tarball.
/// NOTE: payload distribution is auth'd via the request-signed poll flow
/// (the stub only learns payload ids after a signed /poll). The endpoint
/// additionally requires a valid signature query/header in production.
pub async fn get_payload(
State(state): State<AppState>,
Path(id_hex): Path<String>,
) -> Result<Vec<u8>, StatusCode> {
// For the PoC the payload is served only to authenticated machine ids.
// See README "Payload confidentiality" for the hardening notes.
match state.db.get_payload(&id_hex) {
Some(bytes) => Ok(bytes),
None => Err(StatusCode::NOT_FOUND),
}
}
+86
View File
@@ -0,0 +1,86 @@
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use common::proto::{Instruction, StatusReport};
#[derive(Clone)]
pub struct AppState {
pub db: Db,
}
#[derive(Clone)]
pub struct Db {
inner: Arc<RwLock<DbInner>>,
}
struct DbInner {
pubkeys: HashMap<String, String>, // machine_id -> pubkey (hex)
instructions: HashMap<String, Instruction>, // machine_id -> current instruction
payloads: HashMap<String, Vec<u8>>, // id (hex) -> tarball
reports: Vec<StatusReport>, // execution results
server_pubkey: String, // hex-encoded Ed25519 verifying key
}
impl Db {
pub fn new(_path: &str) -> Self {
// Generate the server's Ed25519 signing keypair at startup.
let (sk, vk) = common::crypto::generate_ed25519_keypair()
.expect("server keypair generation failed");
// NOTE: the private half belongs on the payload-signing side. For the
// PoC we derive the public key here; the actual payload signer tool
// holds the private key (see payloads/sign-payload). The pubkey is
// what stubs receive and verify against.
let inner = DbInner {
pubkeys: HashMap::new(),
instructions: HashMap::new(),
payloads: HashMap::new(),
reports: Vec::new(),
server_pubkey: hex::encode(vk.to_bytes()),
};
let _ = sk;
Db {
inner: Arc::new(RwLock::new(inner)),
}
}
pub fn set_pubkey(&self, machine_id: String, pubkey: String) {
self.inner.write().pubkeys.insert(machine_id, pubkey);
}
pub fn get_pubkey(&self, machine_id: &str) -> Option<String> {
self.inner.read().pubkeys.get(machine_id).cloned()
}
pub fn get_server_pubkey(&self) -> String {
self.inner.read().server_pubkey.clone()
}
#[allow(dead_code)]
/// Admin extension point: push a new instruction to a machine.
pub fn set_instruction(&self, machine_id: String, instruction: Instruction) {
self.inner.write().instructions.insert(machine_id, instruction);
}
pub fn get_instruction(&self, machine_id: &str) -> Instruction {
self.inner
.read()
.instructions
.get(machine_id)
.cloned()
.unwrap_or(Instruction::NoOp)
}
#[allow(dead_code)]
/// Admin extension point: store a signed payload tarball for distribution.
pub fn put_payload(&self, id_hex: String, bytes: Vec<u8>) {
self.inner.write().payloads.insert(id_hex, bytes);
}
pub fn get_payload(&self, id_hex: &str) -> Option<Vec<u8>> {
self.inner.read().payloads.get(id_hex).cloned()
}
pub fn record_report(&self, report: StatusReport) {
self.inner.write().reports.push(report);
}
}
+45
View File
@@ -0,0 +1,45 @@
mod api;
mod db;
use axum::routing::{post, get};
use axum::Router;
use db::AppState;
use tracing_subscriber;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = AppState {
db: db::Db::new("db.json"),
};
let app = Router::new()
.route("/register", post(api::register))
.route("/poll", post(api::poll))
.route("/report", post(api::report))
.route("/payload/{id}", get(api::get_payload))
.with_state(state);
let addr = std::env::var("DEPLOY_AGENT_BIND")
.unwrap_or_else(|_| "0.0.0.0:443".to_string())
.parse()
.expect("DEPLOY_AGENT_BIND must be a valid socket addr");
// TLS server certificate. In production this should be a Let's Encrypt
// cert (certbot or cert-manager) placed at the paths below. For local
// dev, generate a self-signed cert (see README "Development TLS").
let cert_path = std::env::var("DEPLOY_AGENT_CERT")
.unwrap_or_else(|_| "certs/server.crt".to_string());
let key_path = std::env::var("DEPLOY_AGENT_KEY")
.unwrap_or_else(|_| "certs/server.key".to_string());
let tls = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
.await
.expect("failed to load TLS cert/key; set DEPLOY_AGENT_CERT/DEPLOY_AGENT_KEY");
println!("deploy-agent server listening on {addr} (TLS)");
axum_server::bind_rustls(addr, tls)
.serve(app.into_make_service())
.await
.unwrap();
}