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 = "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"
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user