diff --git a/Cargo.lock b/Cargo.lock index 91d671da..92db5f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,143 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -103,6 +240,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bootc-internal-blockdev" version = "1.16.4" @@ -190,9 +340,11 @@ dependencies = [ "serde", "serde_json", "signal-hook-registry", + "smol", "tempfile", "uapi-version", "walkdir", + "zlink", ] [[package]] @@ -355,6 +507,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -370,6 +531,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -430,6 +597,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fail" version = "0.5.1" @@ -496,6 +683,67 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -563,6 +811,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hmac" version = "0.12.1" @@ -881,18 +1135,49 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1080,6 +1365,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1180,12 +1471,45 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1273,7 +1597,9 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -1288,6 +1614,31 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1757,6 +2108,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winx" version = "0.36.4" @@ -1875,6 +2232,85 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5961645cac87b613e8b3c6fb1743a567e6459bc8c1d00f7bf9884020fc98d4" +dependencies = [ + "zlink-core", + "zlink-smol", + "zlink-tokio", +] + +[[package]] +name = "zlink-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0314bb8bc5a5693f4625a6ea9681731a5f4bcc0a952bfca28184f268db15dcc2" +dependencies = [ + "futures-util", + "itoa", + "libc", + "pin-project-lite", + "rustix", + "ryu", + "serde", + "serde_json", + "tracing", + "zlink-idl", + "zlink-macros", +] + +[[package]] +name = "zlink-idl" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7990f15bd88a2b37d55c5fc13a1dd662ee98674ec9c6ac904fe46066523932a" +dependencies = [ + "winnow", +] + +[[package]] +name = "zlink-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc623b8323797bf2d48333e7633e143077dd92cc66d9cca170014b66c43b1b5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zlink-idl", +] + +[[package]] +name = "zlink-smol" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc4150b8af92eb0ae7007e0ced3eb2bcf8a7fa49457de2f30b11752ea538f46" +dependencies = [ + "async-broadcast", + "async-channel", + "async-io", + "futures-lite", + "futures-util", + "pin-project-lite", + "zlink-core", +] + +[[package]] +name = "zlink-tokio" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4fe0f232dd656bcb0594e9646b86c69f6257e3d5194f28715a044c4d10c8fa" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", + "tokio-stream", + "zlink-core", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index e3ccd79e..6c30bb53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,8 @@ tempfile = "^3.26" uapi-version = "0.4.0" walkdir = "2.3.2" signal-hook-registry = "1.4.8" +zlink = { version = "0.7.0", default-features = false, features = ["smol", "service", "tracing"] } +smol = "2.0.2" [profile.release] # We assume we're being delivered via e.g. RPM which supports split debuginfo diff --git a/Makefile b/Makefile index 76d52e02..ba0ce4d2 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ install-grub-static: .PHONY: install-systemd-unit install-systemd-unit: - install -m 644 -D -t "${DESTDIR}$(PREFIX)/lib/systemd/system/" systemd/bootloader-update.service + install -m 644 -D -t "${DESTDIR}$(PREFIX)/lib/systemd/system/" systemd/bootloader-update.service systemd/bootupd-varlink.service systemd/bootupd-varlink.socket .PHONY: install-all install-all: install install-grub-static install-systemd-unit diff --git a/contrib/packaging/bootupd.spec b/contrib/packaging/bootupd.spec index 9f427719..564cd95f 100644 --- a/contrib/packaging/bootupd.spec +++ b/contrib/packaging/bootupd.spec @@ -55,6 +55,8 @@ Conflicts: bootc < 1.14.1 %{_libexecdir}/bootupd %{_prefix}/lib/bootupd/grub2-static/ %{_unitdir}/bootloader-update.service +%{_unitdir}/bootupd-varlink.socket +%{_unitdir}/bootupd-varlink.service %prep %autosetup -n %{crate}-%{version} -p1 -a1 diff --git a/src/backend/statefile.rs b/src/backend/statefile.rs index dc9429dc..a2681de0 100644 --- a/src/backend/statefile.rs +++ b/src/backend/statefile.rs @@ -65,7 +65,7 @@ fn get_parent_device(root: &Dir) -> Result { impl SavedState { /// System-wide bootupd write lock (relative to sysroot). - const WRITE_LOCK_PATH: &'static str = "run/bootupd-lock"; + pub(crate) const WRITE_LOCK_PATH: &'static str = "run/bootupd-lock"; /// Top-level directory for statefile (relative to sysroot). pub(crate) const STATEFILE_DIR: &'static str = "boot"; /// On-disk bootloader statefile, akin to a tiny rpm/dpkg database, diff --git a/src/cli/bootupd.rs b/src/cli/bootupd.rs index a9b8951b..3b16d05b 100644 --- a/src/cli/bootupd.rs +++ b/src/cli/bootupd.rs @@ -1,5 +1,7 @@ use crate::bootloader::Bootloader; use crate::bootupd::{self, ConfigMode}; +#[cfg(efi_arch)] +use crate::varlink; use anyhow::{Context, Result}; use camino::Utf8Path; use cap_std::ambient_authority; @@ -42,6 +44,9 @@ pub enum DVerb { Install(InstallOpts), #[cfg(efi_arch)] SetDefaultBootloader(DefaultBootloaderOpts), + #[cfg(efi_arch)] + #[clap(name = "varlink", hide = true, about = "Run the varlink service")] + Varlink, } #[derive(Debug, Parser)] @@ -116,6 +121,8 @@ impl DCommand { DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts), #[cfg(efi_arch)] DVerb::SetDefaultBootloader(opts) => Self::set_default_bootloader(opts), + #[cfg(efi_arch)] + DVerb::Varlink => Self::run_varlink_service(), } } @@ -178,4 +185,14 @@ impl DCommand { Ok(()) } + + fn run_varlink_service() -> Result<()> { + #[cfg(efi_arch)] + { + varlink::run_varlink_service() + } + + #[cfg(not(efi_arch))] + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index 918366a1..753dbd7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,8 @@ mod ostreeutil; mod packagesystem; mod sha512string; mod util; +#[cfg(efi_arch)] +mod varlink; use clap::crate_name; diff --git a/src/varlink.rs b/src/varlink.rs new file mode 100644 index 00000000..5d921973 --- /dev/null +++ b/src/varlink.rs @@ -0,0 +1,216 @@ +use anyhow::{anyhow, Context}; +use cap_std::{ambient_authority, fs::Dir}; +use cap_std_ext::dirext::CapStdExtDirExt; +use log::info; +use std::{ + fs::create_dir_all, + os::{ + fd::IntoRawFd, + unix::io::{FromRawFd, OwnedFd}, + }, + path::{Path, PathBuf}, +}; + +use crate::{ + bootupd::list_dev_current_root, efi::Efi, freezethaw::fsfreeze_thaw_cycle, model::SavedState, +}; + +const SOCKET_PATH: &str = "/run/bootupd/org.coreos.bootupd1"; + +/// Find the ESP device matching a given partition UUID +fn find_esp_by_partuuid<'a>( + devices: &'a [bootc_internal_blockdev::Device], + partuuid: &str, +) -> Option<&'a bootc_internal_blockdev::Device> { + devices.iter().find(|d| { + d.partuuid + .as_deref() + .is_some_and(|u| u.eq_ignore_ascii_case(partuuid)) + }) +} + +#[derive(Debug, Clone, zlink::ReplyError, zlink::introspect::ReplyError)] +#[zlink(interface = "org.coreos.bootupd1")] +enum BootupdVarlinkError { + Failed { message: String }, +} + +impl BootupdVarlinkError { + fn new(message: String) -> Self { + Self::Failed { message } + } +} + +impl From for BootupdVarlinkError { + fn from(err: anyhow::Error) -> Self { + log::error!("varlink call failed: {err:#}"); + Self::Failed { + message: format!("{err}"), + } + } +} + +struct BootupdVarlinkService; + +#[zlink::service(interface = "org.coreos.bootupd1")] +impl BootupdVarlinkService { + /// Sync capsule update files from a "primary" ESP to all colocated ESPs. + /// + /// partuuid: GPT partition UUID of the ESP containing the source capsule files. + /// capsule_dir: Path to the directory containing the source capsule files, relative + /// to the ESP root (e.g. "EFI/fedora/fw") + #[allow(clippy::unused_async)] + async fn sync_fwupd_updates( + &mut self, + partuuid: &str, + capsule_dir: &str, + ) -> Result<(), BootupdVarlinkError> { + if partuuid.is_empty() { + return Err(BootupdVarlinkError::new( + "partuuid must not be empty".into(), + )); + } + if capsule_dir.is_empty() { + return Err(BootupdVarlinkError::new( + "capsule_dir must not be empty".into(), + )); + } + + let capsule_dir = Path::new(capsule_dir); + // Must be relative + if capsule_dir.is_absolute() { + return Err(BootupdVarlinkError::new( + "capsule_dir must be a relative path".into(), + )); + } + // Must not contain path traversal components + if capsule_dir + .components() + .any(|c| c == std::path::Component::ParentDir) + { + return Err(BootupdVarlinkError::new( + "capsule_dir must not contain '..' components - path traversal not allowed".into(), + )); + } + + let root_device = list_dev_current_root()?; + let esp_devices = root_device.find_colocated_esps()?.unwrap_or_default(); + + // Find the source ESP (the one fwupd wrote to). + let primary_device = find_esp_by_partuuid(&esp_devices, partuuid).ok_or_else(|| { + BootupdVarlinkError::new(format!("No ESP found with partuuid {partuuid}")) + })?; + + // Avoid running at the same time as bootloader updates + let sysroot = Dir::open_ambient_dir("/", ambient_authority()).context("opening sysroot")?; + let _lock = SavedState::acquire_write_lock("/".into(), sysroot)?; + + // Mount primary ESP and find capsule updates dir + let primary_efi = Efi::default(); + let primary_mount = + primary_efi.ensure_mounted_esp(Path::new("/"), Path::new(&primary_device.path()))?; + + let src_capsule_path = primary_mount.join(capsule_dir); + if !src_capsule_path.is_dir() { + primary_efi.unmount()?; + return Err(BootupdVarlinkError::new(format!( + "Capsule directory not found at: {src_capsule_path:?}" + ))); + } + + let src_dir = Dir::open_ambient_dir(&src_capsule_path, ambient_authority()) + .context("opening source capsule dir")?; + + // Sync to every other co-located ESP. + let mut synced_count = 0; + for esp in esp_devices.iter().filter(|dev| { + dev.partuuid + .as_ref() + .is_some_and(|u| !u.eq_ignore_ascii_case(partuuid)) + }) { + let secondary_efi = Efi::default(); + let dest_mount = + secondary_efi.ensure_mounted_esp(Path::new("/"), Path::new(&esp.path()))?; + + let dest_capsule_path = dest_mount.join(capsule_dir); + create_dir_all(&dest_capsule_path) + .with_context(|| format!("creating {dest_capsule_path:?}"))?; + + let dest_dir = Dir::open_ambient_dir(&dest_capsule_path, ambient_authority()) + .context("opening destination capsule dir")?; + + for entry in src_dir.entries().context("reading source capsule dir")? { + let entry = entry.context("reading dir entry")?; + let name = entry.file_name(); + let contents = src_dir + .read(&name) + .with_context(|| format!("reading {name:?}"))?; + dest_dir + .write(&name, &contents) + .with_context(|| format!("writing {name:?}"))?; + } + + fsfreeze_thaw_cycle( + dest_dir + .reopen_as_ownedfd() + .context("reopening dest dir as owned fd")?, + )?; + drop(dest_dir); + secondary_efi.unmount()?; + + synced_count += 1; + } + info!("successfully synced {capsule_dir:?} from ESP {partuuid} to {synced_count} colocated ESP(s)"); + + drop(src_dir); + primary_efi.unmount()?; + + Ok(()) + } +} + +/// Ensure the Unix socket can be created +fn get_socket() -> anyhow::Result { + let socket_path = PathBuf::from(SOCKET_PATH); + + // Ensure the parent directory exists. + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating directory {}", parent.display()))?; + } + + // Remove any stale socket from a previous run. + if let Err(e) = std::fs::remove_file(&socket_path) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(e) + .with_context(|| format!("removing stale socket {}", socket_path.display())); + } + } + + Ok(socket_path) +} + +pub fn run_varlink_service() -> anyhow::Result<()> { + smol::block_on(async { + let listener = if std::env::var_os("LISTEN_FDS").is_some() { + // Socket-activated + let fd = libsystemd::activation::receive_descriptors(false) + .context("receiving socket-activated fds")? + .into_iter() + .next() + .ok_or_else(|| anyhow!("no fds received"))?; + // SAFETY: `into_raw_fd` transfers ownership from `FileDescriptor`, ensuring the + // fd is valid and not closed elsewhere. `from_raw_fd` takes exclusive ownership. + let owned_fd = unsafe { OwnedFd::from_raw_fd(fd.into_raw_fd()) }; + zlink::smol::unix::Listener::try_from(owned_fd) + .context("creating listener from socket-activated fd")? + } else { + // Bind our own socket + let socket_path = get_socket()?; + zlink::smol::unix::bind(socket_path)? + }; + + let server = zlink::Server::new(listener, BootupdVarlinkService); + server.run().await.context("running varlink service") + }) +} diff --git a/systemd/bootupd-varlink.service b/systemd/bootupd-varlink.service new file mode 100644 index 00000000..cf8323d0 --- /dev/null +++ b/systemd/bootupd-varlink.service @@ -0,0 +1,17 @@ +[Unit] +Description=Bootupd Varlink Service +Documentation=https://github.com/coreos/bootupd +# Interface only implemented on EFI systems +ConditionPathExists=/sys/firmware/efi + +[Service] +Type=simple +# It doesn't make sense to sync ESP updates in "Live" environments. +# https://github.com/coreos/fedora-coreos-tracker/issues/2136 +ExecCondition=/bin/bash -c '[[ ! $(findmnt -n -o FSTYPE /sysroot) =~ ^(erofs|squashfs)$ ]]' +ExecStart=/usr/libexec/bootupd varlink +# Keep this stuff in sync with SYSTEMD_ARGS_BOOTUPD in general +PrivateNetwork=yes +ProtectHome=yes +KillMode=mixed +MountFlags=slave diff --git a/systemd/bootupd-varlink.socket b/systemd/bootupd-varlink.socket new file mode 100644 index 00000000..e372b2ca --- /dev/null +++ b/systemd/bootupd-varlink.socket @@ -0,0 +1,12 @@ +[Unit] +Description=Bootupd Varlink Socket +Documentation=https://github.com/coreos/bootupd +# Interface only implemented on EFI systems +ConditionPathExists=/sys/firmware/efi + +[Socket] +ListenStream=/run/bootupd/org.coreos.bootupd1 +SocketMode=0600 + +[Install] +WantedBy=sockets.target diff --git a/tests/kola/varlink/data/libtest.sh b/tests/kola/varlink/data/libtest.sh new file mode 120000 index 00000000..59532579 --- /dev/null +++ b/tests/kola/varlink/data/libtest.sh @@ -0,0 +1 @@ +../../data/libtest.sh \ No newline at end of file diff --git a/tests/kola/varlink/data/libtest_varlink.sh b/tests/kola/varlink/data/libtest_varlink.sh new file mode 100644 index 00000000..0f646702 --- /dev/null +++ b/tests/kola/varlink/data/libtest_varlink.sh @@ -0,0 +1,193 @@ +# shellcheck shell=bash +# Common checks for varlink tests. + +set -xeuo pipefail + +# shellcheck disable=SC1091 +. "$KOLA_EXT_DATA/libtest.sh" + +# Need to write files for parsing output +cd "$(mktemp -d)" + +INTERFACE="org.coreos.bootupd1" +SOCKET="/run/bootupd/${INTERFACE}" +SYNC_ENDPOINT="${INTERFACE}.SyncFwupdUpdates" + +CAPSULE_DIR="updates" +CAPSULE_FILE1="test.cap" +CAPSULE_FILE2="a.cap" +CAPSULE_FILE3="b.cap" + +varlink_sync() { + varlinkctl call "${SOCKET}" "${SYNC_ENDPOINT}" \ + "{\"partuuid\": \"$1\", \"capsule_dir\": \"${2:-$CAPSULE_DIR}\"}" > out.txt 2>&1 +} + +check_introspect() { + varlinkctl introspect "${SOCKET}" "${INTERFACE}" > out.txt 2>&1 + assert_file_has_content out.txt "SyncFwupdUpdates" + assert_file_has_content out.txt "partuuid" + assert_file_has_content out.txt "capsule_dir" + ok "introspection shows interface" +} + +check_service() { + unit="bootupd-varlink.socket" + if ! systemctl is-enabled "${unit}" 1> /dev/null; then + # TODO: remove when enabled by default + systemctl start "$unit" + # systemctl status "${unit}" + # fatal "${unit} should be enabled" + fi + ok "${unit} is enabled" +} + +check_failures() { + esp_partuuid=$(lsblk -rn -o PARTLABEL,PARTUUID | awk '$1 ~ /^(EFI-SYSTEM|esp-)/ {print $2}' | head -1) + test -n "${esp_partuuid}" || fatal "ESP has no PARTUUID" + + if varlink_sync "invalid_partuuid" "EFI/test_dir"; then + fatal "bad partuuid should return error" + fi + assert_file_has_content out.txt "No ESP found" + ok "bad partuuid rejected" + + if varlink_sync "${esp_partuuid}" "EFI/test_dir"; then + fatal "missing capsule dir should return error" + fi + assert_file_has_content out.txt "directory not found" + ok "missing capsule dir rejected" + + if varlink_sync "${esp_partuuid}" "/EFI/test_dir"; then + fatal "absolute capsule_dir should be rejected" + fi + assert_file_has_content out.txt "relative path" + ok "absolute path for capsule_dir rejected" + + if varlink_sync "${esp_partuuid}" "../parent/"; then + fatal "capsule_dir with path traversal should be rejected" + fi + assert_file_has_content out.txt "path traversal" + ok "path traversal for capsule_dir rejected" +} + +check_basic() { + check_service + check_introspect + check_failures +} + +check_no_raid() { + efipart=/dev/disk/by-partlabel/EFI-SYSTEM + esp_partuuid=$(lsblk -rn -o PARTUUID "${efipart}" | head -1) + test -n "${esp_partuuid}" || fatal "ESP has no PARTUUID" + + efi_mount=$(mktemp -d) + + # NO FILES + mount "${efipart}" "${efi_mount}" + mkdir -p "${efi_mount}/${CAPSULE_DIR}" + umount "${efi_mount}" + + if ! varlink_sync "${esp_partuuid}"; then + cat out.txt + fatal "failed to sync with no capsule files present" + fi + ok "sync with no capsule files" + + # SINGLE FILE + mount "${efipart}" "${efi_mount}" + echo "test" > "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE1}" + umount "${efi_mount}" + + if ! varlink_sync "${esp_partuuid}"; then + cat out.txt + fatal "failed to sync with 1 capsule file present" + fi + ok "sync with 1 capsule file" + + # MULTIPLE FILES + mount "${efipart}" "${efi_mount}" + assert_file_has_content "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE1}" "test" + echo "test2" > "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE2}" + echo "test3" > "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE3}" + umount "${efi_mount}" + + if ! varlink_sync "${esp_partuuid}"; then + cat out.txt + fatal "failed to sync with multiple capsule files present" + fi + ok "sync with multiple capsule files" + + mount "${efipart}" "${efi_mount}" + assert_file_has_content "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE1}" "test" + assert_file_has_content "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE2}" "test2" + assert_file_has_content "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE3}" "test3" + umount "${efi_mount}" +} + +check_raid() { + esps=$(lsblk -rn -o PATH,PARTLABEL,PARTUUID | awk '$2 ~ /^esp-/') + esp1=$(echo "${esps}" | head -1 | cut -d' ' -f1) + esp2=$(echo "${esps}" | tail -1 | cut -d' ' -f1) + test -n "${esp1}" || fatal "no primary ESP found" + test -n "${esp2}" || fatal "no secondary ESP found" + test "${esp1}" != "${esp2}" || fatal "only found one ESP" + + esp1_partuuid=$(echo "${esps}" | head -1 | cut -d' ' -f3) + test -n "${esp1_partuuid}" || fatal "ESP has no PARTUUID" + + efi_mount=$(mktemp -d) + + # SINGLE FILE + mount "${esp1}" "${efi_mount}" + mkdir -p "${efi_mount}/${CAPSULE_DIR}" + echo "test" > "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE1}" + umount "${efi_mount}" + + if ! varlink_sync "${esp1_partuuid}"; then + cat out.txt + fatal "failed to sync esp from partuuid ${esp1_partuuid} with a single file" + fi + ok "ESP sync of a single file succeeded" + + efi_mount_2=$(mktemp -d) + mount "${esp2}" "${efi_mount_2}" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE1}" "test" + umount "${efi_mount_2}" + + mount "${esp1}" "${efi_mount}" + assert_file_has_content "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE1}" "test" + ok "primary ESP unchanged after sync" + + # MULTIPLE FILES + echo "test2" > "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE2}" + echo "test3" > "${efi_mount}/${CAPSULE_DIR}/${CAPSULE_FILE3}" + umount "${efi_mount}" + + if ! varlink_sync "${esp1_partuuid}"; then + cat out.txt + fatal "failed to sync esp from partuuid: ${esp1_partuuid}" + fi + ok "ESP sync of multiple files succeeded" + + mount "${esp2}" "${efi_mount_2}" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE1}" "test" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE2}" "test2" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE3}" "test3" + umount "${efi_mount_2}" + + # IDEMPOTENCY + if ! varlink_sync "${esp1_partuuid}"; then + cat out.txt + fatal "failed to sync esp from partuuid: ${esp1_partuuid}" + fi + ok "idempotent ESP sync of multiple files succeeded" + + mount "${esp2}" "${efi_mount_2}" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE1}" "test" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE2}" "test2" + assert_file_has_content "${efi_mount_2}/${CAPSULE_DIR}/${CAPSULE_FILE3}" "test3" + umount "${efi_mount_2}" + ok "idempotent sync succeeded" +} diff --git a/tests/kola/varlink/raid/aarch64/config.bu b/tests/kola/varlink/raid/aarch64/config.bu new file mode 100644 index 00000000..e484b83b --- /dev/null +++ b/tests/kola/varlink/raid/aarch64/config.bu @@ -0,0 +1,8 @@ +variant: fcos +version: 1.6.0 +boot_device: + layout: aarch64 + mirror: + devices: + - /dev/vda + - /dev/vdb diff --git a/tests/kola/varlink/raid/aarch64/data/libtest.sh b/tests/kola/varlink/raid/aarch64/data/libtest.sh new file mode 120000 index 00000000..9afee14e --- /dev/null +++ b/tests/kola/varlink/raid/aarch64/data/libtest.sh @@ -0,0 +1 @@ +../../../../data/libtest.sh \ No newline at end of file diff --git a/tests/kola/varlink/raid/aarch64/data/libtest_varlink.sh b/tests/kola/varlink/raid/aarch64/data/libtest_varlink.sh new file mode 120000 index 00000000..dcdd9166 --- /dev/null +++ b/tests/kola/varlink/raid/aarch64/data/libtest_varlink.sh @@ -0,0 +1 @@ +../../../data/libtest_varlink.sh \ No newline at end of file diff --git a/tests/kola/varlink/raid/aarch64/test.sh b/tests/kola/varlink/raid/aarch64/test.sh new file mode 100755 index 00000000..e78b30be --- /dev/null +++ b/tests/kola/varlink/raid/aarch64/test.sh @@ -0,0 +1,18 @@ +#!/bin/bash +## kola: +## # additionalDisks is only supported on qemu. +## platforms: qemu +## # RAID 1 is setup on these disks. +## additionalDisks: ["10G"] +## architectures: "aarch64" +## minMemory: 4096 +## description: Verify that using the bootupd varlink interface to sync capsule updates works. +## creationDate: 2026-08-21 + +# shellcheck disable=SC1091 +. "${KOLA_EXT_DATA}/libtest_varlink.sh" + +check_basic +check_raid + +ok "checks with multiple ESPs successful" diff --git a/tests/kola/varlink/raid/x86_64/config.bu b/tests/kola/varlink/raid/x86_64/config.bu new file mode 100644 index 00000000..5322ad36 --- /dev/null +++ b/tests/kola/varlink/raid/x86_64/config.bu @@ -0,0 +1,8 @@ +variant: fcos +version: 1.6.0 +boot_device: + layout: x86_64 + mirror: + devices: + - /dev/vda + - /dev/vdb diff --git a/tests/kola/varlink/raid/x86_64/data/libtest.sh b/tests/kola/varlink/raid/x86_64/data/libtest.sh new file mode 120000 index 00000000..9afee14e --- /dev/null +++ b/tests/kola/varlink/raid/x86_64/data/libtest.sh @@ -0,0 +1 @@ +../../../../data/libtest.sh \ No newline at end of file diff --git a/tests/kola/varlink/raid/x86_64/data/libtest_varlink.sh b/tests/kola/varlink/raid/x86_64/data/libtest_varlink.sh new file mode 120000 index 00000000..dcdd9166 --- /dev/null +++ b/tests/kola/varlink/raid/x86_64/data/libtest_varlink.sh @@ -0,0 +1 @@ +../../../data/libtest_varlink.sh \ No newline at end of file diff --git a/tests/kola/varlink/raid/x86_64/test.sh b/tests/kola/varlink/raid/x86_64/test.sh new file mode 100755 index 00000000..478ffd8a --- /dev/null +++ b/tests/kola/varlink/raid/x86_64/test.sh @@ -0,0 +1,18 @@ +#!/bin/bash +## kola: +## # additionalDisks is only supported on qemu. +## platforms: qemu +## # RAID 1 is setup on these disks. +## additionalDisks: ["10G"] +## architectures: "x86_64" +## minMemory: 4096 +## description: Verify that using the bootupd varlink interface to sync capsule updates works. +## creationDate: 2026-08-21 + +# shellcheck disable=SC1091 +. "${KOLA_EXT_DATA}/libtest_varlink.sh" + +check_basic +check_raid + +ok "checks with multiple ESPs successful" diff --git a/tests/kola/varlink/single-esp.sh b/tests/kola/varlink/single-esp.sh new file mode 100755 index 00000000..a9c59648 --- /dev/null +++ b/tests/kola/varlink/single-esp.sh @@ -0,0 +1,20 @@ +#!/bin/bash +## kola: +## exclusive: false +## description: Verify the bootupd varlink service is enabled, +## and fails when used incorrectly. +## tags: "platform-independent" +## creationDate: 2026-08-21 + +# shellcheck disable=SC1091 +. "${KOLA_EXT_DATA}/libtest_varlink.sh" + +if [ ! -d /sys/firmware/efi ]; then + echo "Not an EFI system - skipping" + exit 0 +fi + +check_basic +check_no_raid + +ok "checks with a single ESP successful"