mod install;
mod protocol;
mod system;
use install::{Paths, Settings, Updater};
use protocol::{Request, Response};
use std::{
io::{BufRead, BufReader, Read, Write},
os::fd::FromRawFd,
os::unix::net::{UnixListener, UnixStream},
path::PathBuf,
time::Duration,
};
use system::{RealSystem, System};
use update_core::signature::TRUSTED_PUBLIC_KEYS;
const ALLOWED_DIR: &str = "/var/lib/rustploy/updates";
const STATE_DIR: &str = "/var/lib/rustploy-update";
const MAX_DEB_BYTES: u64 = 200 * 1024 * 1024;
const MAX_REQUEST_BYTES: u64 = 4096;
fn listener() -> (UnixListener, bool) {
if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") {
return (unsafe { UnixListener::from_raw_fd(3) }, false);
}
let path = std::env::var("RUSTPLOY_UPDATE_SOCKET").unwrap_or_else(|error| {
eprintln!(
"rustployd-update: sem LISTEN_FDS do systemd nem RUSTPLOY_UPDATE_SOCKET ({error})"
);
std::process::exit(1);
});
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap_or_else(|error| {
eprintln!("rustployd-update: falha ao criar o socket {path}: {error}");
std::process::exit(1);
});
(listener, true)
}
fn trusted_keys() -> Vec<String> {
match option_env!("RUSTPLOY_E2E_TRUSTED_KEY") {
Some(key) if !key.is_empty() => vec![key.to_string()],
_ => TRUSTED_PUBLIC_KEYS
.iter()
.map(|key| key.to_string())
.collect(),
}
}
fn directory(variable: &str, default: &str, development: bool) -> PathBuf {
match std::env::var(variable) {
Ok(path) if development => PathBuf::from(path),
_ => PathBuf::from(default),
}
}
fn main() {
let (listener, development) = listener();
let paths = Paths {
allowed_dir: directory("RUSTPLOY_UPDATE_ALLOWED_DIR", ALLOWED_DIR, development),
state_dir: directory("RUSTPLOY_UPDATE_STATE_DIR", STATE_DIR, development),
managed_binaries: vec![
PathBuf::from("/usr/bin/rustployd"),
PathBuf::from("/usr/libexec/rustployd-fw"),
PathBuf::from("/usr/libexec/rustployd-update"),
],
};
let settings = Settings {
package: "rustployd".into(),
service: "rustployd".into(),
trusted_keys: trusted_keys(),
max_deb_bytes: MAX_DEB_BYTES,
poll_interval: Duration::from_secs(1),
stable_polls: 15,
max_polls: 90,
};
let system = RealSystem;
let updater = Updater {
paths: &paths,
settings: &settings,
system: &system,
};
eprintln!("rustployd-update: pronto (atende um pedido e sai)");
match listener.accept() {
Ok((stream, _)) => serve(stream, &updater),
Err(error) => eprintln!("rustployd-update: accept: {error}"),
}
}
fn serve<S: System>(stream: UnixStream, updater: &Updater<'_, S>) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let Ok(mut writer) = stream.try_clone() else {
eprintln!("rustployd-update: não consegui clonar a conexão");
return;
};
let mut line = String::new();
if BufReader::new(stream.take(MAX_REQUEST_BYTES))
.read_line(&mut line)
.is_err()
{
let _ = writer.write_all(
Response::failed("pedido ilegível ou lento demais")
.to_line()
.as_bytes(),
);
return;
}
handle_line(updater, &line, &mut |response| {
let _ = writer.write_all(response.to_line().as_bytes());
let _ = writer.flush();
});
}
fn handle_line<S: System>(updater: &Updater<'_, S>, line: &str, reply: &mut dyn FnMut(Response)) {
let request = match serde_json::from_str::<Request>(line.trim()) {
Ok(request) => request,
Err(error) => {
reply(Response::failed(format!("pedido inválido: {error}")));
return;
}
};
match request {
Request::Install { deb, sig } => match updater.prepare(&deb, &sig) {
Ok(prepared) => {
eprintln!(
"rustployd-update: aceito {}, instalando",
prepared.facts.version
);
reply(Response::accepted(Some(prepared.facts.version.clone())));
let outcome = updater.execute(prepared);
eprintln!("rustployd-update: {:?}: {}", outcome.kind, outcome.message);
}
Err(message) => {
eprintln!("rustployd-update: recusado: {message}");
reply(Response::failed(message));
}
},
Request::Rollback => match updater.prepare_rollback() {
Ok(plan) => {
eprintln!(
"rustployd-update: revertendo para {:?}",
plan.previous_version
);
reply(Response::accepted(Some(plan.previous_version.clone())));
let outcome = updater.execute_rollback(plan);
eprintln!("rustployd-update: {:?}: {}", outcome.kind, outcome.message);
}
Err(message) => reply(Response::failed(message)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{cell::RefCell, io, time::Duration};
use system::CommandOutput;
struct Nothing;
impl System for Nothing {
fn run(
&self,
program: &str,
_args: &[&str],
_envs: &[(&str, &str)],
) -> io::Result<CommandOutput> {
Err(io::Error::other(format!("não devia rodar {program}")))
}
fn sleep(&self, _duration: Duration) {}
}
fn with_updater<R>(test: impl FnOnce(&Updater<'_, Nothing>) -> R) -> R {
let root = tempfile::tempdir().unwrap();
let paths = Paths {
allowed_dir: root.path().join("updates"),
state_dir: root.path().join("state"),
managed_binaries: vec![],
};
std::fs::create_dir_all(&paths.allowed_dir).unwrap();
let settings = Settings {
package: "rustployd".into(),
service: "rustployd".into(),
trusted_keys: vec![],
max_deb_bytes: 1024,
poll_interval: Duration::ZERO,
stable_polls: 1,
max_polls: 1,
};
test(&Updater {
paths: &paths,
settings: &settings,
system: &Nothing,
})
}
fn replies(line: &str) -> Vec<Response> {
with_updater(|updater| {
let collected = RefCell::new(vec![]);
handle_line(updater, line, &mut |response| {
collected.borrow_mut().push(response)
});
collected.into_inner()
})
}
#[test]
fn a_normal_build_trusts_only_the_production_keys() {
assert!(
option_env!("RUSTPLOY_E2E_TRUSTED_KEY").is_none_or(str::is_empty),
"RUSTPLOY_E2E_TRUSTED_KEY está definida neste build: não é um build de produção"
);
let expected: Vec<String> = TRUSTED_PUBLIC_KEYS
.iter()
.map(|key| key.to_string())
.collect();
assert_eq!(trusted_keys(), expected);
}
#[test]
fn garbage_and_unknown_verbs_get_one_failed_reply() {
for line in [
"",
"isto não é json",
r#"{"op":"exec","cmd":"id"}"#,
r#"{"op":"install"}"#,
] {
let replies = replies(line);
assert_eq!(replies.len(), 1, "{line:?}");
assert!(!replies[0].ok, "{line:?}");
}
}
#[test]
fn a_path_outside_the_updates_dir_is_refused_in_one_reply() {
let replies = replies(r#"{"op":"install","deb":"/etc/passwd","sig":"/etc/shadow"}"#);
assert_eq!(replies.len(), 1);
assert!(!replies[0].ok);
assert!(
replies[0]
.error
.as_deref()
.unwrap()
.contains("pacote recusado")
);
}
#[test]
fn rollback_without_a_backup_is_refused() {
let replies = replies(r#"{"op":"rollback"}"#);
assert_eq!(replies.len(), 1);
assert!(!replies[0].ok);
}
}