use mlua::Lua;
pub fn register_systemd(lua: &Lua) -> mlua::Result<()> {
let t = lua.create_table()?;
#[cfg(target_os = "linux")]
impl_linux::register(lua, &t)?;
#[cfg(not(target_os = "linux"))]
{
for name in &[
"list_units",
"unit_status",
"is_active",
"list_timers",
"start",
"stop",
"restart",
"reload",
"list_machines",
"machine_status",
"machine_start",
"machine_poweroff",
"machine_reboot",
"machine_terminate",
"machine_exec", "journal",
"journal_follow",
] {
let n = name.to_string();
let f = lua.create_async_function(move |_, _args: mlua::MultiValue| {
let n = n.clone();
async move {
Err::<mlua::Value, _>(mlua::Error::runtime(format!("systemd.{n}: Linux only")))
}
})?;
t.set(*name, f)?;
}
}
lua.globals().set("systemd", t)?;
Ok(())
}
#[cfg(target_os = "linux")]
mod impl_linux {
use mlua::{Lua, Table};
use std::sync::Arc;
use tokio::sync::OnceCell;
use zbus::{Connection, zvariant::OwnedObjectPath};
static SYSTEM_BUS: OnceCell<Arc<Connection>> = OnceCell::const_new();
async fn system_bus() -> Result<Arc<Connection>, zbus::Error> {
SYSTEM_BUS
.get_or_try_init(|| async {
let conn = Connection::system().await?;
Ok(Arc::new(conn))
})
.await
.cloned()
}
async fn manager_proxy(conn: &Connection) -> zbus::Result<zbus::Proxy<'_>> {
zbus::proxy::Builder::new(conn)
.destination("org.freedesktop.systemd1")?
.path("/org/freedesktop/systemd1")?
.interface("org.freedesktop.systemd1.Manager")?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.await
}
async fn machine_manager_proxy(conn: &Connection) -> zbus::Result<zbus::Proxy<'_>> {
zbus::proxy::Builder::new(conn)
.destination("org.freedesktop.machine1")?
.path("/org/freedesktop/machine1")?
.interface("org.freedesktop.machine1.Manager")?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.await
}
async fn unit_proxy<'c>(conn: &'c Connection, path: &str) -> zbus::Result<zbus::Proxy<'c>> {
zbus::proxy::Builder::new(conn)
.destination("org.freedesktop.systemd1")?
.path(path.to_string())?
.interface("org.freedesktop.systemd1.Unit")?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.await
}
async fn timer_proxy<'c>(conn: &'c Connection, path: &str) -> zbus::Result<zbus::Proxy<'c>> {
zbus::proxy::Builder::new(conn)
.destination("org.freedesktop.systemd1")?
.path(path.to_string())?
.interface("org.freedesktop.systemd1.Timer")?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.await
}
async fn machine_proxy<'c>(conn: &'c Connection, path: &str) -> zbus::Result<zbus::Proxy<'c>> {
zbus::proxy::Builder::new(conn)
.destination("org.freedesktop.machine1")?
.path(path.to_string())?
.interface("org.freedesktop.machine1.Machine")?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.await
}
fn glob_matches(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(suffix) = pattern.strip_prefix('*') {
name.ends_with(suffix)
} else if let Some(prefix) = pattern.strip_suffix('*') {
name.starts_with(prefix)
} else {
name == pattern
}
}
type UnitRow = (
String, String, String, String, String, String, OwnedObjectPath, u32, String, OwnedObjectPath, );
type MachineRow = (String, String, String, OwnedObjectPath);
pub fn register(lua: &Lua, t: &Table) -> mlua::Result<()> {
register_units(lua, t)?;
register_machines(lua, t)?;
register_journal(lua, t)?;
register_machine_exec(lua, t)?; register_unit_action(lua, t)?; Ok(())
}
fn register_units(lua: &Lua, t: &Table) -> mlua::Result<()> {
let list_units = lua.create_async_function(|lua, filter: Option<String>| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_units: {e}")))?;
let mgr = manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_units: {e}")))?;
let reply = mgr
.call_method("ListUnits", &())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_units: {e}")))?;
let rows: Vec<UnitRow> = reply.body().deserialize().map_err(|e| {
mlua::Error::runtime(format!("systemd.list_units: deserialize: {e}"))
})?;
let out = lua.create_table()?;
let mut idx = 1usize;
for row in &rows {
if let Some(ref pat) = filter
&& !glob_matches(pat, &row.0)
{
continue;
}
let entry = lua.create_table()?;
entry.set("name", row.0.clone())?;
entry.set("description", row.1.clone())?;
entry.set("load", row.2.clone())?;
entry.set("active", row.3.clone())?;
entry.set("sub", row.4.clone())?;
entry.set("following", row.5.clone())?;
entry.set("unit_object_path", row.6.as_str().to_string())?;
out.set(idx, entry)?;
idx += 1;
}
Ok(out)
})?;
t.set("list_units", list_units)?;
let unit_status = lua.create_async_function(|lua, name: String| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.unit_status: {e}")))?;
let mgr = manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.unit_status: {e}")))?;
let reply = mgr
.call_method("LoadUnit", &name.as_str())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.unit_status: LoadUnit: {e}")))?;
let obj_path: OwnedObjectPath = reply
.body()
.deserialize()
.map_err(|e| mlua::Error::runtime(format!("systemd.unit_status: path: {e}")))?;
let uproxy = unit_proxy(&conn, obj_path.as_str())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.unit_status: proxy: {e}")))?;
let entry = lua.create_table()?;
entry.set("name", name.clone())?;
entry.set(
"load",
uproxy
.get_property::<String>("LoadState")
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.unit_status: LoadState: {e}"))
})?,
)?;
entry.set(
"active",
uproxy
.get_property::<String>("ActiveState")
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.unit_status: ActiveState: {e}"))
})?,
)?;
entry.set(
"sub",
uproxy
.get_property::<String>("SubState")
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.unit_status: SubState: {e}"))
})?,
)?;
entry.set(
"description",
uproxy
.get_property::<String>("Description")
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.unit_status: Description: {e}"))
})?,
)?;
entry.set(
"fragment_path",
uproxy
.get_property::<String>("FragmentPath")
.await
.unwrap_or_default(),
)?;
entry.set("unit_object_path", obj_path.as_str().to_string())?;
if let Ok(pid) = uproxy.get_property::<u32>("MainPID").await {
entry.set("main_pid", pid)?;
}
if let Ok(status) = uproxy.get_property::<i32>("ExecMainStatus").await {
entry.set("exec_main_status", status)?;
}
if let Ok(ts) = uproxy.get_property::<u64>("ActiveEnterTimestamp").await {
entry.set("since", ts)?;
}
Ok(entry)
})?;
t.set("unit_status", unit_status)?;
let is_active = lua.create_async_function(|lua, name: String| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.is_active: {e}")))?;
let mgr = manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.is_active: {e}")))?;
let reply = mgr
.call_method("LoadUnit", &name.as_str())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.is_active: LoadUnit: {e}")))?;
let obj_path: OwnedObjectPath = reply
.body()
.deserialize()
.map_err(|e| mlua::Error::runtime(format!("systemd.is_active: path: {e}")))?;
let uproxy = unit_proxy(&conn, obj_path.as_str())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.is_active: proxy: {e}")))?;
let active: String = uproxy.get_property("ActiveState").await.map_err(|e| {
mlua::Error::runtime(format!("systemd.is_active: ActiveState: {e}"))
})?;
lua.pack(active == "active")
})?;
t.set("is_active", is_active)?;
let list_timers = lua.create_async_function(|lua, ()| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_timers: {e}")))?;
let mgr = manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_timers: {e}")))?;
let reply = mgr
.call_method("ListUnits", &())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_timers: {e}")))?;
let rows: Vec<UnitRow> = reply.body().deserialize().map_err(|e| {
mlua::Error::runtime(format!("systemd.list_timers: deserialize: {e}"))
})?;
let out = lua.create_table()?;
let mut idx = 1usize;
for row in rows {
if !row.0.ends_with(".timer") {
continue;
}
let tproxy = timer_proxy(&conn, row.6.as_str()).await.map_err(|e| {
mlua::Error::runtime(format!("systemd.list_timers: timer proxy: {e}"))
})?;
let next_elapse: u64 = tproxy
.get_property("NextElapseUSecRealtime")
.await
.unwrap_or(0);
let last_trigger: u64 = tproxy.get_property("LastTriggerUSec").await.unwrap_or(0);
let activates: String = tproxy.get_property("Unit").await.unwrap_or_default();
let now_usec = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
let passed = next_elapse > 0 && now_usec > next_elapse;
let entry = lua.create_table()?;
entry.set("unit", row.0.clone())?;
entry.set("next_elapse_realtime", next_elapse)?;
entry.set("last_trigger_realtime", last_trigger)?;
entry.set("passed", passed)?;
entry.set("activates", activates)?;
out.set(idx, entry)?;
idx += 1;
}
Ok(out)
})?;
t.set("list_timers", list_timers)?;
for (lua_name, dbus_method) in &[
("start", "StartUnit"),
("stop", "StopUnit"),
("restart", "RestartUnit"),
("reload", "ReloadUnit"),
] {
let dbus_method = dbus_method.to_string();
let lua_name_str = lua_name.to_string();
let f = lua.create_async_function(move |_lua, name: String| {
let dbus_method = dbus_method.clone();
let lua_name_str = lua_name_str.clone();
async move {
let conn = system_bus().await.map_err(|e| {
mlua::Error::runtime(format!("systemd.{lua_name_str}: {e}"))
})?;
let mgr = manager_proxy(&conn).await.map_err(|e| {
mlua::Error::runtime(format!("systemd.{lua_name_str}: {e}"))
})?;
let reply = mgr
.call_method(dbus_method.as_str(), &(name.as_str(), "replace"))
.await
.map_err(|e| {
mlua::Error::runtime(format!(
"systemd.{lua_name_str}: {dbus_method}: {e}"
))
})?;
let job_path: OwnedObjectPath = reply.body().deserialize().map_err(|e| {
mlua::Error::runtime(format!("systemd.{lua_name_str}: job path: {e}"))
})?;
Ok(job_path.as_str().to_string())
}
})?;
t.set(*lua_name, f)?;
}
Ok(())
}
fn register_machines(lua: &Lua, t: &Table) -> mlua::Result<()> {
let list_machines = lua.create_async_function(|lua, ()| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_machines: {e}")))?;
let mgr = machine_manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_machines: {e}")))?;
let reply = mgr
.call_method("ListMachines", &())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.list_machines: {e}")))?;
let rows: Vec<MachineRow> = reply.body().deserialize().map_err(|e| {
mlua::Error::runtime(format!("systemd.list_machines: deserialize: {e}"))
})?;
let out = lua.create_table()?;
for (idx, row) in rows.iter().enumerate() {
let mproxy = machine_proxy(&conn, row.3.as_str()).await.map_err(|e| {
mlua::Error::runtime(format!("systemd.list_machines: machine proxy: {e}"))
})?;
let leader: u32 = mproxy.get_property("Leader").await.unwrap_or(0);
let root_dir: String = mproxy
.get_property("RootDirectory")
.await
.unwrap_or_default();
let entry = lua.create_table()?;
entry.set("name", row.0.clone())?;
entry.set("class", row.1.clone())?;
entry.set("service", row.2.clone())?;
entry.set("leader_pid", leader)?;
entry.set("root_directory", root_dir)?;
entry.set("addresses", lua.create_table()?)?;
out.set(idx + 1, entry)?;
}
Ok(out)
})?;
t.set("list_machines", list_machines)?;
let machine_status = lua.create_async_function(|lua, name: String| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_status: {e}")))?;
let mgr = machine_manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_status: {e}")))?;
let reply = mgr
.call_method("GetMachine", &name.as_str())
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.machine_status: GetMachine: {e}"))
})?;
let obj_path: OwnedObjectPath = reply
.body()
.deserialize()
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_status: path: {e}")))?;
let mproxy = machine_proxy(&conn, obj_path.as_str())
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_status: proxy: {e}")))?;
let entry = lua.create_table()?;
entry.set(
"name",
mproxy.get_property::<String>("Name").await.map_err(|e| {
mlua::Error::runtime(format!("systemd.machine_status: Name: {e}"))
})?,
)?;
entry.set(
"class",
mproxy.get_property::<String>("Class").await.map_err(|e| {
mlua::Error::runtime(format!("systemd.machine_status: Class: {e}"))
})?,
)?;
entry.set(
"service",
mproxy
.get_property::<String>("Service")
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.machine_status: Service: {e}"))
})?,
)?;
entry.set(
"root_directory",
mproxy
.get_property::<String>("RootDirectory")
.await
.unwrap_or_default(),
)?;
if let Ok(leader) = mproxy.get_property::<u32>("Leader").await {
entry.set("leader_pid", leader)?;
}
if let Ok(ts) = mproxy.get_property::<u64>("Timestamp").await {
entry.set("timestamp", ts)?;
}
entry.set("addresses", lua.create_table()?)?;
Ok(entry)
})?;
t.set("machine_status", machine_status)?;
let machine_start = lua.create_async_function(|_lua, name: String| async move {
let conn = system_bus()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_start: {e}")))?;
let mgr = manager_proxy(&conn)
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_start: {e}")))?;
let svc = format!("systemd-nspawn@{name}.service");
let reply = mgr
.call_method("StartUnit", &(svc.as_str(), "replace"))
.await
.map_err(|e| {
mlua::Error::runtime(format!("systemd.machine_start: StartUnit: {e}"))
})?;
let job_path: OwnedObjectPath = reply.body().deserialize().map_err(|e| {
mlua::Error::runtime(format!("systemd.machine_start: job path: {e}"))
})?;
Ok(job_path.as_str().to_string())
})?;
t.set("machine_start", machine_start)?;
for (lua_name, dbus_method) in &[
("machine_poweroff", "PowerOff"),
("machine_reboot", "Reboot"),
("machine_terminate", "Terminate"),
] {
let dbus_method = dbus_method.to_string();
let lua_name_str = lua_name.to_string();
let f = lua.create_async_function(move |_lua, name: String| {
let dbus_method = dbus_method.clone();
let lua_name_str = lua_name_str.clone();
async move {
let conn = system_bus().await.map_err(|e| {
mlua::Error::runtime(format!("systemd.{lua_name_str}: {e}"))
})?;
let mgr = machine_manager_proxy(&conn).await.map_err(|e| {
mlua::Error::runtime(format!("systemd.{lua_name_str}: {e}"))
})?;
mgr.call_method(dbus_method.as_str(), &name.as_str())
.await
.map_err(|e| {
mlua::Error::runtime(format!(
"systemd.{lua_name_str}: {dbus_method}: {e}"
))
})?;
Ok(())
}
})?;
t.set(*lua_name, f)?;
}
Ok(())
}
fn register_journal(lua: &Lua, t: &Table) -> mlua::Result<()> {
let journal = lua.create_async_function(|lua, opts: Option<mlua::Table>| async move {
let (unit, machine, since, until, lines, priority, elevate) = if let Some(ref o) = opts
{
(
o.get::<Option<String>>("unit")?,
o.get::<Option<String>>("machine")?,
o.get::<Option<String>>("since")?,
o.get::<Option<String>>("until")?,
o.get::<Option<u32>>("lines")?.unwrap_or(200),
o.get::<Option<u8>>("priority")?.unwrap_or(7),
o.get::<Option<bool>>("elevate")?.unwrap_or(false),
)
} else {
(None, None, None, None, 200u32, 7u8, false)
};
let mut cmd = if elevate {
let mut c = tokio::process::Command::new("sudo");
c.arg("-n").arg("journalctl");
c
} else {
tokio::process::Command::new("journalctl")
};
cmd.arg("--output=json")
.arg("--no-pager")
.arg(format!("-n{lines}"))
.arg(format!("--priority={priority}"));
if let Some(ref u) = unit {
cmd.arg(format!("--unit={u}"));
}
if let Some(ref m) = machine {
cmd.arg(format!("--machine={m}"));
}
if let Some(ref s) = since {
cmd.arg(format!("--since={s}"));
}
if let Some(ref u) = until {
cmd.arg(format!("--until={u}"));
}
let output = cmd
.output()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.journal: journalctl: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(mlua::Error::runtime(format!(
"systemd.journal: journalctl exited non-zero: {stderr}"
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let out = lua.create_table()?;
let mut idx = 1usize;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let v: serde_json::Value = serde_json::from_str(line).map_err(|e| {
mlua::Error::runtime(format!("systemd.journal: JSON parse: {e}"))
})?;
let obj = match v.as_object() {
Some(o) => o,
None => continue,
};
let get_str = |key: &str| -> String {
obj.get(key)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
let ts: u64 = obj
.get("__REALTIME_TIMESTAMP")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let pri: u8 = obj
.get("PRIORITY")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or(7);
let entry = lua.create_table()?;
entry.set("ts", ts)?;
entry.set("hostname", get_str("_HOSTNAME"))?;
entry.set("unit", get_str("_SYSTEMD_UNIT"))?;
entry.set("message", get_str("MESSAGE"))?;
entry.set("priority", pri)?;
entry.set("transport", get_str("_TRANSPORT"))?;
out.set(idx, entry)?;
idx += 1;
}
Ok(out)
})?;
t.set("journal", journal)?;
let journal_follow = lua.create_async_function(
|_lua, (opts, cb): (Option<mlua::Table>, mlua::Function)| async move {
use std::sync::{Arc, atomic::AtomicBool};
use tokio::sync::mpsc;
let unit = opts
.as_ref()
.and_then(|o| o.get::<Option<String>>("unit").ok().flatten());
let machine = opts
.as_ref()
.and_then(|o| o.get::<Option<String>>("machine").ok().flatten());
let since = opts
.as_ref()
.and_then(|o| o.get::<Option<u64>>("since").ok().flatten());
let priority = opts
.as_ref()
.and_then(|o| o.get::<Option<u8>>("priority").ok().flatten())
.unwrap_or(7);
let cancel = Arc::new(AtomicBool::new(false));
let (tx, mut rx) = mpsc::channel::<journal_follow_impl::JournalEntry>(64);
let cancel_bg = Arc::clone(&cancel);
tokio::task::spawn_blocking(move || {
journal_follow_impl::follow_loop(unit, machine, since, priority, cancel_bg, tx);
});
let handle = journal_follow_impl::FollowHandle {
cancel: Arc::clone(&cancel),
};
tokio::select! {
_ = async {
while let Some(entry) = rx.recv().await {
let t = _lua.create_table()?;
t.set("ts", entry.ts)?;
t.set("hostname", entry.hostname)?;
t.set("unit", entry.unit)?;
t.set("message", entry.message)?;
t.set("priority", entry.priority)?;
t.set("transport", entry.transport)?;
cb.call_async::<()>(t).await?;
}
Ok::<(), mlua::Error>(())
} => {}
};
Ok(handle)
},
)?;
t.set("journal_follow", journal_follow)?;
Ok(())
}
fn register_machine_exec(lua: &Lua, t: &Table) -> mlua::Result<()> {
let machine_exec = lua.create_async_function(|lua, args: mlua::MultiValue| async move {
let mut args_iter = args.into_iter();
let machine: String = match args_iter.next() {
Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
_ => {
return Err(mlua::Error::runtime(
"systemd.machine_exec: first arg must be machine name string",
));
}
};
let command: String = match args_iter.next() {
Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
_ => {
return Err(mlua::Error::runtime(
"systemd.machine_exec: second arg must be command string",
));
}
};
let opts: Option<mlua::Table> = match args_iter.next() {
Some(mlua::Value::Table(t)) => Some(t),
_ => None,
};
let timeout_secs: Option<f64> =
opts.as_ref().and_then(|t| t.get::<f64>("timeout").ok());
if let Some(secs) = timeout_secs
&& (!secs.is_finite() || secs < 0.0)
{
return Err(mlua::Error::runtime(
"systemd.machine_exec: timeout must be a non-negative finite number",
));
}
let stdin_bytes: Option<Vec<u8>> = match opts.as_ref() {
Some(t) => t
.get::<Option<mlua::String>>("stdin")?
.map(|s| s.as_bytes().to_vec()),
None => None,
};
let mut tokio_cmd = tokio::process::Command::new("systemd-run");
tokio_cmd
.arg(format!("--machine={machine}"))
.arg("--pipe")
.arg("--quiet")
.arg("--wait")
.arg("--collect");
tokio_cmd.kill_on_drop(true);
if let Some(ref t) = opts
&& let Ok(env_table) = t.get::<mlua::Table>("env")
{
for pair in env_table.pairs::<String, String>() {
let (k, v) = pair?;
tokio_cmd.arg(format!("--setenv={k}={v}"));
}
}
tokio_cmd.arg("/bin/sh").arg("-c").arg(&command);
tokio_cmd.stdin(if stdin_bytes.is_some() {
std::process::Stdio::piped()
} else {
std::process::Stdio::null()
});
tokio_cmd.stdout(std::process::Stdio::piped());
tokio_cmd.stderr(std::process::Stdio::piped());
let mut child = tokio_cmd
.spawn()
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_exec: spawn: {e}")))?;
if let Some(bytes) = stdin_bytes.as_deref()
&& let Some(mut child_stdin) = child.stdin.take()
{
use tokio::io::AsyncWriteExt;
if let Err(e) = child_stdin.write_all(bytes).await {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(mlua::Error::runtime(format!(
"systemd.machine_exec: stdin write: {e}"
)));
}
let _ = child_stdin.shutdown().await;
}
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut s) = stdout_handle {
use tokio::io::AsyncReadExt;
let _ = s.read_to_end(&mut buf).await;
}
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut s) = stderr_handle {
use tokio::io::AsyncReadExt;
let _ = s.read_to_end(&mut buf).await;
}
buf
});
let status = if let Some(secs) = timeout_secs.filter(|s| *s > 0.0) {
match tokio::time::timeout(std::time::Duration::from_secs_f64(secs), child.wait())
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
return Err(mlua::Error::runtime(format!(
"systemd.machine_exec: wait: {e}"
)));
}
Err(_elapsed) => {
let _ = child.start_kill();
let _ = child.wait().await;
stdout_task.abort();
stderr_task.abort();
let result = lua.create_table()?;
result.set("status", -1i64)?;
result.set("stdout", "")?;
result.set("stderr", "")?;
result.set("timed_out", true)?;
return Ok(result);
}
}
} else {
child
.wait()
.await
.map_err(|e| mlua::Error::runtime(format!("systemd.machine_exec: wait: {e}")))?
};
let stdout_bytes = stdout_task.await.unwrap_or_default();
let stderr_bytes = stderr_task.await.unwrap_or_default();
let output = std::process::Output {
status,
stdout: stdout_bytes,
stderr: stderr_bytes,
};
let result = lua.create_table()?;
result.set("status", output.status.code().unwrap_or(-1) as i64)?;
result.set(
"stdout",
String::from_utf8_lossy(&output.stdout).to_string(),
)?;
result.set(
"stderr",
String::from_utf8_lossy(&output.stderr).to_string(),
)?;
result.set("timed_out", false)?;
Ok(result)
})?;
t.set("machine_exec", machine_exec)?;
Ok(())
}
fn register_unit_action(lua: &Lua, t: &Table) -> mlua::Result<()> {
const ALLOWED: &[&str] = &[
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"daemon-reload",
"is-active",
"is-enabled",
];
const DEFAULT_TIMEOUT_SECS: f64 = 60.0;
fn validate_unit(unit: &str) -> mlua::Result<()> {
if unit.is_empty() {
return Err(mlua::Error::runtime(
"systemd.unit_action: unit name must be non-empty",
));
}
if unit.starts_with('-') {
return Err(mlua::Error::runtime(format!(
"systemd.unit_action: unit {unit:?} must not start with '-'"
)));
}
if !unit.chars().all(|c| {
c.is_ascii_alphanumeric()
|| c == '.'
|| c == '_'
|| c == '-'
|| c == ':'
|| c == '@'
|| c == '\\'
}) {
return Err(mlua::Error::runtime(format!(
"systemd.unit_action: unit {unit:?} contains disallowed characters"
)));
}
Ok(())
}
let unit_action = lua.create_async_function(|lua, args: mlua::MultiValue| async move {
let mut iter = args.into_iter();
let arg1: String = iter
.next()
.ok_or_else(|| mlua::Error::runtime("systemd.unit_action: action required"))
.and_then(|v| lua.unpack(v))?;
let arg2: Option<String> = match iter.next() {
Some(mlua::Value::String(s)) => Some(s.to_str()?.to_string()),
Some(mlua::Value::Nil) | None => None,
Some(_) => {
return Err(mlua::Error::runtime(
"systemd.unit_action: second arg must be string or nil",
));
}
};
let opts: Option<Table> = match iter.next() {
Some(mlua::Value::Table(t)) => Some(t),
_ => None,
};
let (unit, action) = match arg2 {
Some(action) => (Some(arg1), action),
None => (None, arg1),
};
if !ALLOWED.iter().any(|a| *a == action) {
return Err(mlua::Error::runtime(format!(
"systemd.unit_action: action {action:?} not in allowlist {ALLOWED:?}"
)));
}
let timeout_secs = opts
.as_ref()
.and_then(|t| t.get::<Option<f64>>("timeout").ok().flatten())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
if !timeout_secs.is_finite() || timeout_secs <= 0.0 {
return Err(mlua::Error::runtime(
"systemd.unit_action: timeout must be positive finite",
));
}
let timeout = std::time::Duration::from_secs_f64(timeout_secs);
let mut argv: Vec<String> = vec![action.clone()];
if let Some(u) = unit {
validate_unit(&u)?;
argv.push("--".to_string());
argv.push(u);
}
let argv_refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
let is_root = unsafe { libc::getuid() } == 0;
let mut cmd = if is_root {
let mut c = tokio::process::Command::new("systemctl");
c.args(&argv_refs);
c
} else {
let mut c = tokio::process::Command::new("sudo");
c.arg("-n").arg("systemctl");
c.args(&argv_refs);
c
};
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| mlua::Error::runtime(format!("systemd.unit_action: spawn: {e}")))?;
let stdout_h = child.stdout.take();
let stderr_h = child.stderr.take();
let stdout_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut s) = stdout_h {
use tokio::io::AsyncReadExt;
let _ = s.read_to_end(&mut buf).await;
}
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut s) = stderr_h {
use tokio::io::AsyncReadExt;
let _ = s.read_to_end(&mut buf).await;
}
buf
});
let result = lua.create_table()?;
match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => {
let stdout = stdout_task.await.unwrap_or_default();
let stderr = stderr_task.await.unwrap_or_default();
result.set("status", status.code().unwrap_or(-1) as i64)?;
result.set("stdout", String::from_utf8_lossy(&stdout).to_string())?;
result.set("stderr", String::from_utf8_lossy(&stderr).to_string())?;
}
Ok(Err(e)) => {
return Err(mlua::Error::runtime(format!(
"systemd.unit_action: wait: {e}"
)));
}
Err(_elapsed) => {
let _ = child.start_kill();
let _ = child.wait().await;
stdout_task.abort();
stderr_task.abort();
return Err(mlua::Error::runtime(format!(
"systemd.unit_action: timed out after {}s",
timeout.as_secs()
)));
}
}
Ok(result)
})?;
t.set("unit_action", unit_action)?;
Ok(())
}
pub(super) mod journal_follow_impl {
use libloading::{Library, Symbol};
use std::{
ffi::{CString, c_char, c_int, c_void},
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
},
};
use tokio::sync::mpsc;
use tracing::warn;
type SdJournal = c_void;
#[allow(non_snake_case)]
struct Syms {
sd_journal_open: unsafe extern "C" fn(*mut *mut SdJournal, c_int) -> c_int,
sd_journal_close: unsafe extern "C" fn(*mut SdJournal),
sd_journal_seek_tail: unsafe extern "C" fn(*mut SdJournal) -> c_int,
sd_journal_seek_realtime_usec: unsafe extern "C" fn(*mut SdJournal, u64) -> c_int,
sd_journal_add_match:
unsafe extern "C" fn(*mut SdJournal, *const c_void, usize) -> c_int,
sd_journal_next: unsafe extern "C" fn(*mut SdJournal) -> c_int,
sd_journal_previous: unsafe extern "C" fn(*mut SdJournal) -> c_int,
sd_journal_wait: unsafe extern "C" fn(*mut SdJournal, u64) -> c_int,
sd_journal_get_data: unsafe extern "C" fn(
*mut SdJournal,
*const c_char,
*mut *const c_void,
*mut usize,
) -> c_int,
sd_journal_get_realtime_usec: unsafe extern "C" fn(*mut SdJournal, *mut u64) -> c_int,
}
unsafe impl Send for Syms {}
unsafe impl Sync for Syms {}
static SYMS: OnceLock<Result<Syms, String>> = OnceLock::new();
fn load_syms() -> Result<&'static Syms, String> {
SYMS.get_or_init(|| unsafe {
let lib = Library::new("libsystemd.so.0").map_err(|e| {
format!("systemd.journal_follow: libsystemd.so.0 not found on this host: {e}")
})?;
macro_rules! sym {
($name:ident) => {{
let s: Symbol<_> = lib.get(stringify!($name).as_bytes()).map_err(|e| {
format!("systemd.journal_follow: symbol {}: {e}", stringify!($name))
})?;
*s
}};
}
let syms = Syms {
sd_journal_open: sym!(sd_journal_open),
sd_journal_close: sym!(sd_journal_close),
sd_journal_seek_tail: sym!(sd_journal_seek_tail),
sd_journal_seek_realtime_usec: sym!(sd_journal_seek_realtime_usec),
sd_journal_add_match: sym!(sd_journal_add_match),
sd_journal_next: sym!(sd_journal_next),
sd_journal_previous: sym!(sd_journal_previous),
sd_journal_wait: sym!(sd_journal_wait),
sd_journal_get_data: sym!(sd_journal_get_data),
sd_journal_get_realtime_usec: sym!(sd_journal_get_realtime_usec),
};
std::mem::forget(lib);
Ok(syms)
})
.as_ref()
.map_err(|e| e.clone())
}
struct Journal {
ptr: *mut SdJournal,
syms: &'static Syms,
}
unsafe impl Send for Journal {}
impl Journal {
fn open() -> Result<Self, String> {
let syms = load_syms()?;
let mut ptr: *mut SdJournal = std::ptr::null_mut();
let rc = unsafe { (syms.sd_journal_open)(&mut ptr, 1) };
if rc < 0 {
return Err(format!(
"systemd.journal_follow: sd_journal_open failed: {rc}"
));
}
Ok(Journal { ptr, syms })
}
fn seek_tail(&self) -> c_int {
unsafe { (self.syms.sd_journal_seek_tail)(self.ptr) }
}
fn seek_realtime_usec(&self, usec: u64) -> c_int {
unsafe { (self.syms.sd_journal_seek_realtime_usec)(self.ptr, usec) }
}
fn add_match(&self, expr: &str) -> c_int {
let bytes = expr.as_bytes();
unsafe {
(self.syms.sd_journal_add_match)(
self.ptr,
bytes.as_ptr() as *const c_void,
bytes.len(),
)
}
}
fn next(&self) -> c_int {
unsafe { (self.syms.sd_journal_next)(self.ptr) }
}
fn previous(&self) -> c_int {
unsafe { (self.syms.sd_journal_previous)(self.ptr) }
}
fn wait(&self, timeout_us: u64) -> c_int {
unsafe { (self.syms.sd_journal_wait)(self.ptr, timeout_us) }
}
fn read_field(&self, field: &str) -> Option<String> {
let cfield = CString::new(field).ok()?;
let mut data: *const c_void = std::ptr::null();
let mut len: usize = 0;
let rc = unsafe {
(self.syms.sd_journal_get_data)(self.ptr, cfield.as_ptr(), &mut data, &mut len)
};
if rc < 0 || data.is_null() {
return None;
}
let bytes = unsafe { std::slice::from_raw_parts(data as *const u8, len) };
let prefix = format!("{field}=");
let raw = std::str::from_utf8(bytes).unwrap_or("");
Some(raw.strip_prefix(&prefix).unwrap_or(raw).to_owned())
}
fn realtime_usec(&self) -> u64 {
let mut usec: u64 = 0;
unsafe { (self.syms.sd_journal_get_realtime_usec)(self.ptr, &mut usec) };
usec
}
}
impl Drop for Journal {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { (self.syms.sd_journal_close)(self.ptr) };
}
}
}
#[derive(Debug)]
pub struct JournalEntry {
pub ts: u64,
pub hostname: String,
pub unit: String,
pub message: String,
pub priority: u8,
pub transport: String,
}
pub fn follow_loop(
unit: Option<String>,
machine: Option<String>,
since: Option<u64>,
priority: u8,
cancel: Arc<AtomicBool>,
tx: mpsc::Sender<JournalEntry>,
) {
let journal = match Journal::open() {
Ok(j) => j,
Err(e) => {
warn!("{e}");
return;
}
};
if let Some(ref u) = unit {
journal.add_match(&format!("_SYSTEMD_UNIT={u}"));
}
if let Some(ref m) = machine {
let machine_id = resolve_machine_id(m);
match machine_id {
Some(id) => {
journal.add_match(&format!("_MACHINE_ID={id}"));
}
None => {
warn!(
"systemd.journal_follow: could not resolve machine ID for '{}'; \
falling back to _MACHINE= match",
m
);
journal.add_match(&format!("_MACHINE={m}"));
}
}
}
for p in 0..=priority {
journal.add_match(&format!("PRIORITY={p}"));
}
if let Some(secs) = since {
journal.seek_realtime_usec(secs * 1_000_000);
} else {
journal.seek_tail();
journal.previous();
}
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
loop {
let r = journal.next();
if r <= 0 {
break;
}
let entry = JournalEntry {
ts: journal.realtime_usec(),
hostname: journal.read_field("_HOSTNAME").unwrap_or_default(),
unit: journal.read_field("_SYSTEMD_UNIT").unwrap_or_default(),
message: journal.read_field("MESSAGE").unwrap_or_default(),
priority: journal
.read_field("PRIORITY")
.and_then(|s| s.parse().ok())
.unwrap_or(7),
transport: journal.read_field("_TRANSPORT").unwrap_or_default(),
};
if tx.blocking_send(entry).is_err() {
return; }
}
if cancel.load(Ordering::Relaxed) {
break;
}
journal.wait(500_000);
}
}
fn resolve_machine_id(name: &str) -> Option<String> {
let path = format!("/run/systemd/machines/{name}");
let content = std::fs::read_to_string(&path).ok()?;
for line in content.lines() {
if let Some(id) = line.strip_prefix("MACHINE_ID=") {
let id = id.trim();
if id.len() == 32 && id.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(id.to_owned());
}
}
}
None
}
pub struct FollowHandle {
pub cancel: Arc<AtomicBool>,
}
impl mlua::UserData for FollowHandle {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("close", |_, this, ()| {
this.cancel.store(true, Ordering::Relaxed);
Ok(())
});
methods.add_method("is_closed", |_, this, ()| {
Ok(this.cancel.load(Ordering::Relaxed))
});
}
}
impl Drop for FollowHandle {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
}
#[cfg(test)]
mod tests {
use mlua::{Lua, LuaOptions, StdLib};
fn make_lua() -> Lua {
Lua::new_with(StdLib::ALL_SAFE, LuaOptions::default()).expect("Lua init failed")
}
fn setup(lua: &Lua) {
let t = lua.create_table().unwrap();
super::register(lua, &t).unwrap();
lua.globals().set("systemd", t).unwrap();
}
#[tokio::test]
#[ignore = "requires running system bus (systemd)"]
async fn list_units_returns_init_scope() {
let lua = make_lua();
setup(&lua);
let result: mlua::Table = lua
.load("return systemd.list_units()")
.eval_async()
.await
.expect("list_units failed");
let found = (1..=result.raw_len()).any(|i| {
result
.get::<mlua::Table>(i)
.ok()
.and_then(|t| t.get::<String>("name").ok())
.map(|n| n == "init.scope")
.unwrap_or(false)
});
assert!(found, "init.scope not found in list_units()");
}
#[tokio::test]
#[ignore = "requires running system bus (systemd)"]
async fn is_active_returns_bool() {
let lua = make_lua();
setup(&lua);
let active: bool = lua
.load(r#"return systemd.is_active("init.scope")"#)
.eval_async()
.await
.expect("is_active failed");
assert!(active, "init.scope should be active");
}
#[tokio::test]
#[ignore = "requires running system bus (systemd)"]
async fn list_machines_callable() {
let lua = make_lua();
setup(&lua);
let _result: mlua::Table = lua
.load("return systemd.list_machines()")
.eval_async()
.await
.expect("list_machines failed");
}
#[tokio::test]
#[ignore = "requires journalctl (systemd)"]
async fn journal_smoke() {
let lua = make_lua();
setup(&lua);
let result: mlua::Table = lua
.load("return systemd.journal({lines=5})")
.eval_async()
.await
.expect("journal failed");
let len = result.raw_len();
assert!(len <= 5, "expected <= 5 entries, got {len}");
for i in 1..=len {
let entry: mlua::Table = result.get(i).unwrap();
let _msg: String = entry.get("message").expect("entry.message missing");
}
}
#[tokio::test]
#[ignore = "requires journalctl (systemd)"]
async fn journal_filter_by_priority() {
let lua = make_lua();
setup(&lua);
let result: mlua::Table = lua
.load("return systemd.journal({lines=10, priority=3})")
.eval_async()
.await
.expect("journal priority filter failed");
for i in 1..=result.raw_len() {
let entry: mlua::Table = result.get(i).unwrap();
let pri: u8 = entry.get("priority").unwrap_or(255);
assert!(pri <= 3, "expected priority <= 3, got {pri} in entry {i}");
}
}
#[tokio::test]
#[ignore = "requires journal write access (logger)"]
async fn journal_follow_smoke() {
use super::journal_follow_impl;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tokio::sync::mpsc;
use tokio::time::{Duration, timeout};
tokio::process::Command::new("logger")
.args(["-t", "assay-jf-test", "marker-XYZ"])
.status()
.await
.expect("logger failed");
tokio::time::sleep(Duration::from_millis(200)).await;
let cancel = Arc::new(AtomicBool::new(false));
let (tx, mut rx) = mpsc::channel::<journal_follow_impl::JournalEntry>(64);
let cancel_bg = Arc::clone(&cancel);
let since = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(2),
);
tokio::task::spawn_blocking(move || {
journal_follow_impl::follow_loop(None, None, since, 7, cancel_bg, tx);
});
let found = timeout(Duration::from_secs(5), async {
while let Some(entry) = rx.recv().await {
if entry.message.contains("marker-XYZ") {
return true;
}
}
false
})
.await
.unwrap_or(false);
cancel.store(true, Ordering::Relaxed);
assert!(found, "marker-XYZ not seen within 5 s");
}
#[tokio::test]
#[ignore = "requires journal write access (logger)"]
async fn journal_follow_priority_filter() {
use super::journal_follow_impl;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tokio::sync::mpsc;
use tokio::time::{Duration, timeout};
tokio::process::Command::new("logger")
.args(["-p", "user.err", "-t", "assay-jf-test", "err-marker-XYZ"])
.status()
.await
.expect("logger failed");
tokio::process::Command::new("logger")
.args(["-p", "user.info", "-t", "assay-jf-test", "info-marker-XYZ"])
.status()
.await
.expect("logger failed");
tokio::time::sleep(Duration::from_millis(200)).await;
let cancel = Arc::new(AtomicBool::new(false));
let (tx, mut rx) = mpsc::channel::<journal_follow_impl::JournalEntry>(64);
let cancel_bg = Arc::clone(&cancel);
let since = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(2),
);
tokio::task::spawn_blocking(move || {
journal_follow_impl::follow_loop(None, None, since, 3, cancel_bg, tx);
});
let mut entries: Vec<(String, u8)> = Vec::new();
let found_err = timeout(Duration::from_secs(5), async {
while let Some(entry) = rx.recv().await {
let is_err = entry.message.contains("err-marker-XYZ");
entries.push((entry.message.clone(), entry.priority));
if is_err {
return true;
}
}
false
})
.await
.unwrap_or(false);
cancel.store(true, Ordering::Relaxed);
assert!(found_err, "err-marker-XYZ not seen within 5 s");
let info_leaked = entries.iter().any(|(m, _)| m.contains("info-marker-XYZ"));
assert!(
!info_leaked,
"info-marker-XYZ should have been filtered by priority=3"
);
for (msg, pri) in &entries {
assert!(*pri <= 3, "got priority {pri} > 3 for message: {msg}");
}
}
#[tokio::test]
#[ignore = "requires journal write access (logger)"]
async fn journal_follow_close_idempotent() {
use super::journal_follow_impl::FollowHandle;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
let cancel = Arc::new(AtomicBool::new(false));
let handle = FollowHandle {
cancel: Arc::clone(&cancel),
};
handle.cancel.store(true, Ordering::Relaxed);
assert!(handle.cancel.load(Ordering::Relaxed));
handle.cancel.store(true, Ordering::Relaxed);
assert!(handle.cancel.load(Ordering::Relaxed));
}
}
}