diff --git a/Makefile-rpm-ostree.am b/Makefile-rpm-ostree.am index 427909c999..939b4ae15d 100644 --- a/Makefile-rpm-ostree.am +++ b/Makefile-rpm-ostree.am @@ -29,6 +29,7 @@ rpm_ostree_SOURCES = src/app/main.c \ src/app/rpmostree-builtin-reload.c \ src/app/rpmostree-builtin-rebase.c \ src/app/rpmostree-builtin-cancel.c \ + src/app/rpmostree-builtin-cliwrap.c \ src/app/rpmostree-builtin-cleanup.c \ src/app/rpmostree-builtin-initramfs.c \ src/app/rpmostree-builtin-livefs.c \ @@ -102,7 +103,7 @@ librpmostree_rust_path = @abs_top_builddir@/target/@RUST_TARGET_SUBDIR@/librpmos # If the target directory exists, and isn't owned by our uid, then # we exit with a fatal error, since someone probably did `make && sudo make install`, # and in this case cargo will download into ~/.root which we don't want. -LIBRPMOSTREE_RUST_SRCS = $(wildcard rust/src/*.rs) rust/cbindgen.toml +LIBRPMOSTREE_RUST_SRCS = $(shell find rust/src/ -name '*.rs') rust/cbindgen.toml $(librpmostree_rust_path): Makefile $(LIBRPMOSTREE_RUST_SRCS) cd $(top_srcdir)/rust && \ export CARGO_TARGET_DIR=@abs_top_builddir@/target && \ diff --git a/docs/manual/treefile.md b/docs/manual/treefile.md index 160cd91b00..9bcd7c4dfb 100644 --- a/docs/manual/treefile.md +++ b/docs/manual/treefile.md @@ -94,6 +94,12 @@ It supports the following parameters: specific filesystem drivers are included. If not specified, `--no-hostonly` will be used. + * `cliwrap`: boolean, optional. Defaults to `false`. If enabled, + rpm-ostree will replace binaries such as `/usr/bin/rpm` with + wrappers that intercept unsafe operations, or adjust functionality. + + The default is `false` out of conservatism; you likely want to enable this. + * `remove-files`: Array of files to delete from the generated tree. * `remove-from-packages`: Array, optional: Delete from specified packages diff --git a/rust/src/cliwrap.rs b/rust/src/cliwrap.rs new file mode 100644 index 0000000000..7a90acd4a5 --- /dev/null +++ b/rust/src/cliwrap.rs @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2019 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + */ + +use anyhow::{bail, Result}; +use std::io::prelude::*; +use std::{io, path}; + +use openat_ext::OpenatDirExt; +use rayon::prelude::*; +mod cliutil; +mod dracut; +mod grubby; +mod rpm; + +/// Location for the underlying (not wrapped) binaries. +pub const CLIWRAP_DESTDIR: &'static str = "usr/libexec/rpm-ostree/wrapped"; + +/// Our list of binaries that will be wrapped. Must be a relative path. +static WRAPPED_BINARIES: &[&str] = &["usr/bin/rpm", "usr/bin/dracut", "usr/sbin/grubby"]; + +#[derive(Debug, PartialEq)] +pub(crate) enum RunDisposition { + Ok, + Warn, + Notice(String), +} + +/// Main entrypoint for cliwrap +fn cliwrap_main(args: &Vec) -> Result<()> { + // We'll panic here if the vector is empty, but that is intentional; + // the outer code should always pass us at least one arg. + let name = args[0].as_str(); + let name = match std::path::Path::new(name).file_name() { + Some(name) => name, + None => bail!("Invalid wrapped binary: {}", name), + }; + // We know we had a string from above + let name = name.to_str().unwrap(); + + let args: Vec<&str> = args.iter().skip(1).map(|v| v.as_str()).collect(); + + // If we're not booted into ostree, just run the child directly. + if !cliutil::is_ostree_booted() { + cliutil::exec_real_binary(name, &args) + } else { + match name { + "rpm" => self::rpm::main(&args), + "dracut" => self::dracut::main(&args), + "grubby" => self::grubby::main(&args), + _ => bail!("Unknown wrapped binary: {}", name), + } + } +} + +/// Move the real binaries to a subdir, and replace them with +/// a shell script that calls our wrapping code. +fn write_wrappers(rootfs_dfd: &openat::Dir) -> Result<()> { + let destdir = std::path::Path::new(CLIWRAP_DESTDIR); + rootfs_dfd.ensure_dir(destdir.parent().unwrap(), 0o755)?; + rootfs_dfd.ensure_dir(destdir, 0o755)?; + WRAPPED_BINARIES.par_iter().try_for_each(|&bin| { + let binpath = path::Path::new(bin); + + if !rootfs_dfd.exists(binpath)? { + return Ok(()); + } + + let name = binpath.file_name().unwrap().to_str().unwrap(); + let destpath = format!("{}/{}", CLIWRAP_DESTDIR, name); + rootfs_dfd.local_rename(bin, destpath.as_str())?; + + let f = rootfs_dfd.write_file(binpath, 0o755)?; + let mut f = io::BufWriter::new(f); + write!( + f, + "#!/bin/sh +# Wrapper created by rpm-ostree to override +# behavior of the underlying binary. For more +# information see `man rpm-ostree`. The real +# binary is now located at: {} +exec /usr/bin/rpm-ostree cliwrap $0 \"$@\" +", + binpath.to_str().unwrap() + )?; + f.flush()?; + Ok(()) + }) +} + +mod ffi { + use super::*; + use crate::ffiutil::*; + use anyhow::Context; + use glib; + use lazy_static::lazy_static; + use libc; + use std::ffi::CString; + + #[no_mangle] + pub extern "C" fn ror_cliwrap_write_wrappers( + rootfs_dfd: libc::c_int, + gerror: *mut *mut glib_sys::GError, + ) -> libc::c_int { + let rootfs_dfd = ffi_view_openat_dir(rootfs_dfd); + int_glib_error( + write_wrappers(&rootfs_dfd).with_context(|| format!("cli wrapper replacement failed")), + gerror, + ) + } + + #[no_mangle] + pub extern "C" fn ror_cliwrap_entrypoint( + argv: *mut *mut libc::c_char, + gerror: *mut *mut glib_sys::GError, + ) -> libc::c_int { + let v: Vec = unsafe { glib::translate::FromGlibPtrContainer::from_glib_none(argv) }; + int_glib_error(cliwrap_main(&v), gerror) + } + + #[no_mangle] + pub extern "C" fn ror_cliwrap_destdir() -> *const libc::c_char { + lazy_static! { + static ref CLIWRAP_DESTDIR_C: CString = CString::new(CLIWRAP_DESTDIR).unwrap(); + } + CLIWRAP_DESTDIR_C.as_ptr() + } +} +pub use self::ffi::*; diff --git a/rust/src/cliwrap/cliutil.rs b/rust/src/cliwrap/cliutil.rs new file mode 100644 index 0000000000..5cf2f37f4b --- /dev/null +++ b/rust/src/cliwrap/cliutil.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use nix::sys::statvfs; +use std::os::unix::process::CommandExt; +use std::{path, thread, time}; + +use crate::cliwrap; + +/// Returns true if the current process is booted via ostree. +pub fn is_ostree_booted() -> bool { + path::Path::new("/run/ostree-booted").exists() +} + +/// Returns true if /usr is not a read-only bind mount +pub fn is_unlocked() -> Result { + Ok(!statvfs::statvfs("/usr")? + .flags() + .contains(statvfs::FsFlags::ST_RDONLY)) +} + +/// Returns true if the current process is running as root. +pub fn am_privileged() -> bool { + nix::unistd::getuid() == nix::unistd::Uid::from_raw(0) +} + +/// Return the absolute path to the underlying wrapped binary +fn get_real_binary_path(bin_name: &str) -> String { + format!("/{}/{}", cliwrap::CLIWRAP_DESTDIR, bin_name) +} + +/// Wrapper for execv which accepts strings +pub fn exec_real_binary + std::fmt::Display>(bin_name: T, argv: &[T]) -> Result<()> { + let bin_name = bin_name.as_ref(); + let real_bin = get_real_binary_path(bin_name); + let mut proc = std::process::Command::new(real_bin); + proc.args(argv.iter().map(|s| s.as_ref())); + Err(proc.exec().into()) +} + +/// Run a subprocess synchronously as user `bin` (dropping all capabilities). +pub fn run_unprivileged>( + with_warning: bool, + target_bin: &str, + argv: &[T], +) -> Result<()> { + // `setpriv` is in util-linux; we could do this internally, but this is easier. + let setpriv_argv = &[ + "setpriv", + "--no-new-privs", + "--reuid=bin", + "--regid=bin", + "--init-groups", + "--bounding-set", + "-all", + "--", + ]; + + let argv: Vec<&str> = argv.into_iter().map(AsRef::as_ref).collect(); + let drop_privileges = am_privileged(); + let app_name = "rpm-ostree"; + if with_warning { + let delay_s = 5; + eprintln!( + "{name}: NOTE: This system is ostree based.", + name = app_name + ); + if drop_privileges { + eprintln!( + r#"{name}: Dropping privileges as `{bin}` was executed with not "known safe" arguments."#, + name = app_name, + bin = target_bin + ); + } else { + eprintln!( + r#"{name}: Wrapped binary "{bin}" was executed with not "known safe" arguments."#, + name = app_name, + bin = target_bin + ); + } + eprintln!( + r##"{name}: You may invoke the real `{bin}` binary in `/{wrap_destdir}/{bin}`. +{name}: Continuing execution in {delay} seconds. +"##, + name = app_name, + wrap_destdir = cliwrap::CLIWRAP_DESTDIR, + bin = target_bin, + delay = delay_s, + ); + thread::sleep(time::Duration::from_secs(delay_s)); + } + + if drop_privileges { + let real_bin = get_real_binary_path(target_bin); + let mut proc = std::process::Command::new("setpriv"); + proc.args(setpriv_argv); + proc.arg(real_bin); + proc.args(argv); + Err(proc.exec().into()) + } else { + exec_real_binary(target_bin, &argv) + } +} diff --git a/rust/src/cliwrap/dracut.rs b/rust/src/cliwrap/dracut.rs new file mode 100644 index 0000000000..78489e49e3 --- /dev/null +++ b/rust/src/cliwrap/dracut.rs @@ -0,0 +1,17 @@ +use anyhow::Result; + +use crate::cliwrap::cliutil; + +/// Primary entrypoint to running our wrapped `dracut` handling. +pub(crate) fn main(argv: &[&str]) -> Result<()> { + eprintln!( + "This system is rpm-ostree based; initramfs handling is +integrated with the underlying ostree transaction mechanism. +Use `rpm-ostree initramfs` to control client-side initramfs generation." + ); + if argv.len() > 0 { + Ok(cliutil::run_unprivileged(true, "dracut", argv)?) + } else { + std::process::exit(1); + } +} diff --git a/rust/src/cliwrap/grubby.rs b/rust/src/cliwrap/grubby.rs new file mode 100644 index 0000000000..e88b5964a9 --- /dev/null +++ b/rust/src/cliwrap/grubby.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +/// Primary entrypoint to running our wrapped `grubby` handling. +pub(crate) fn main(_argv: &[&str]) -> Result<()> { + eprintln!( + "This system is rpm-ostree based; grubby is not used. +Use `rpm-ostree kargs` instead." + ); + std::process::exit(1); +} diff --git a/rust/src/cliwrap/rpm.rs b/rust/src/cliwrap/rpm.rs new file mode 100644 index 0000000000..e114413b5b --- /dev/null +++ b/rust/src/cliwrap/rpm.rs @@ -0,0 +1,137 @@ +use anyhow::Result; +use clap::{App, Arg}; + +use crate::cliwrap::cliutil; +use crate::cliwrap::RunDisposition; + +fn new_rpm_app<'r>() -> App<'r, 'static> { + let name = "cli-ostree-wrapper-rpm"; + App::new(name) + .bin_name(name) + .version("0.1") + .about("Wrapper for rpm") + .arg(Arg::with_name("verify").short("V")) + .arg(Arg::with_name("version")) +} + +// clap doesn't easily allow us to parse unknown arguments right now, +// scan argv manually. +// https://github.com/clap-rs/clap/issues/873#issuecomment-436546860 +fn has_query(argv: &[&str]) -> bool { + for a in argv { + let a = *a; + if a == "--query" { + return true; + } + if a.starts_with("-") && !a.starts_with("--") { + for c in a.chars().skip(1) { + if c == 'q' { + return true; + } + } + } + } + false +} + +fn disposition(argv: &[&str]) -> Result { + // Today rpm has --query take precendence over --erase and --install + // apparently, so let's just accept anything with --query as there + // are a lot of sub-options for that. + if has_query(argv) { + return Ok(RunDisposition::Ok); + } + + let mut app = new_rpm_app(); + let matches = match app.get_matches_from_safe_borrow(std::iter::once(&"rpm").chain(argv.iter())) + { + Ok(v) => v, + Err(e) if e.kind == clap::ErrorKind::VersionDisplayed => return Ok(RunDisposition::Ok), + _ => return Ok(RunDisposition::Warn), + }; + + if matches.is_present("verify") { + Ok(RunDisposition::Notice( + "rpm --verify is not necessary for ostree-based systems. + All binaries in /usr are underneath a read-only bind mount. + If you wish to verify integrity, use `ostree fsck`." + .to_string(), + )) + } else { + // This currently really shoudln't happen, but in the future we might + // clearly whitelist other arguments besides --query. + Ok(RunDisposition::Ok) + } +} + +/// Primary entrypoint to running our wrapped `rpm` handling. +pub(crate) fn main(argv: &[&str]) -> Result<()> { + if cliutil::is_unlocked()? { + // For now if we're unlocked, just directly exec rpm. In the future we + // may choose to take over installing a package live. + cliutil::exec_real_binary("rpm", argv) + } else { + match disposition(argv)? { + RunDisposition::Ok => cliutil::run_unprivileged(false, "rpm", argv), + RunDisposition::Warn => cliutil::run_unprivileged(true, "rpm", argv), + RunDisposition::Notice(ref s) => { + println!("{}", s); + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() -> Result<()> { + assert_eq!(disposition(&["--version"])?, RunDisposition::Ok); + Ok(()) + } + + #[test] + fn test_query_all() -> Result<()> { + assert_eq!(disposition(&["-qa"])?, RunDisposition::Ok); + Ok(()) + } + + #[test] + fn test_query_file() -> Result<()> { + assert_eq!( + disposition(&["--query", "-f", "/usr/bin/bash"])?, + RunDisposition::Ok + ); + Ok(()) + } + + #[test] + fn test_query_requires() -> Result<()> { + assert_eq!( + disposition(&["--requires", "-q", "blah"])?, + RunDisposition::Ok + ); + Ok(()) + } + + #[test] + fn test_query_erase() -> Result<()> { + // Note --query overrides --erase today + assert_eq!(disposition(&["-qea", "bash"])?, RunDisposition::Ok); + Ok(()) + } + + #[test] + fn test_erase() -> Result<()> { + assert_eq!(disposition(&["--erase", "bash"])?, RunDisposition::Warn); + Ok(()) + } + + #[test] + fn test_shorterase() -> Result<()> { + assert_eq!(disposition(&["-e", "bash"])?, RunDisposition::Warn); + Ok(()) + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 561184e2ab..b2821e9278 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -7,6 +7,8 @@ // pub(crate) utilities mod ffiutil; +mod cliwrap; +pub use cliwrap::*; mod composepost; pub use self::composepost::*; mod history; diff --git a/rust/src/treefile.rs b/rust/src/treefile.rs index 6a931b8a5e..26f4530b24 100644 --- a/rust/src/treefile.rs +++ b/rust/src/treefile.rs @@ -296,6 +296,7 @@ fn treefile_merge(dest: &mut TreeComposeConfig, src: &mut TreeComposeConfig) { include, container, recommends, + cliwrap, documentation, boot_location, tmp_is_dir, @@ -684,6 +685,8 @@ struct TreeComposeConfig { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "initramfs-args")] initramfs_args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + cliwrap: Option, // Tree layout options #[serde(skip_serializing_if = "Option::is_none")] @@ -1366,6 +1369,12 @@ mod ffi { tf.checksum.as_ptr() } + #[no_mangle] + pub extern "C" fn ror_treefile_get_cliwrap(tf: *mut Treefile) -> bool { + let tf = ref_from_raw_ptr(tf); + tf.parsed.cliwrap.unwrap_or(false) + } + #[no_mangle] pub extern "C" fn ror_treefile_free(tf: *mut Treefile) { if tf.is_null() { diff --git a/src/app/main.c b/src/app/main.c index 24aebf9449..93e870090b 100644 --- a/src/app/main.c +++ b/src/app/main.c @@ -128,6 +128,8 @@ static RpmOstreeCommand commands[] = { NULL, rpmostree_builtin_start_daemon }, { "finalize-deployment", RPM_OSTREE_BUILTIN_FLAG_HIDDEN, NULL, rpmostree_builtin_finalize_deployment }, + { "cliwrap", RPM_OSTREE_BUILTIN_FLAG_LOCAL_CMD | RPM_OSTREE_BUILTIN_FLAG_HIDDEN, + NULL, rpmostree_builtin_cliwrap }, { NULL } }; diff --git a/src/app/rpmostree-builtin-cliwrap.c b/src/app/rpmostree-builtin-cliwrap.c new file mode 100644 index 0000000000..0616615b4d --- /dev/null +++ b/src/app/rpmostree-builtin-cliwrap.c @@ -0,0 +1,48 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- + * + * Copyright (C) 2019 Red Hat, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation; either version 2 of the licence or (at + * your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General + * Public License along with this library; if not, write to the + * Free Software Foundation, Inc., 59 Temple Place, Suite 330, + * Boston, MA 02111-1307, USA. + */ + +#include "config.h" + +#include +#include +#include + +#include "rpmostree-builtins.h" +#include "rpmostree-libbuiltin.h" +#include "rpmostree-rust.h" + +#include + +gboolean +rpmostree_builtin_cliwrap (int argc, + char **argv, + RpmOstreeCommandInvocation *invocation, + GCancellable *cancellable, + GError **error) +{ + if (argc < 2) + return glnx_throw (error, "cliwrap: missing required subcommand"); + + g_autoptr(GPtrArray) args = g_ptr_array_new (); + for (int i = 1; i < argc; i++) + g_ptr_array_add (args, argv[i]); + g_ptr_array_add (args, NULL); + return ror_cliwrap_entrypoint ((char**)args->pdata, error); +} diff --git a/src/app/rpmostree-builtins.h b/src/app/rpmostree-builtins.h index e5af602905..6c41bf3368 100644 --- a/src/app/rpmostree-builtins.h +++ b/src/app/rpmostree-builtins.h @@ -31,6 +31,7 @@ G_BEGIN_DECLS GCancellable *cancellable, GError **error) BUILTINPROTO(compose); +BUILTINPROTO(cliwrap); BUILTINPROTO(upgrade); BUILTINPROTO(reload); BUILTINPROTO(usroverlay); diff --git a/src/libpriv/rpmostree-core.c b/src/libpriv/rpmostree-core.c index b7f705c477..ca4ed2294f 100644 --- a/src/libpriv/rpmostree-core.c +++ b/src/libpriv/rpmostree-core.c @@ -4420,6 +4420,12 @@ rpmostree_context_assemble (RpmOstreeContext *self, return FALSE; } + if (self->treefile_rs && ror_treefile_get_cliwrap (self->treefile_rs)) + { + if (!ror_cliwrap_write_wrappers (tmprootfs_dfd, error)) + return FALSE; + } + /* Undo the /etc move above */ if (renamed_etc && !rpmostree_core_redo_usretc (tmprootfs_dfd, error)) return FALSE; diff --git a/src/libpriv/rpmostree-kernel.c b/src/libpriv/rpmostree-kernel.c index e88d8c8006..e1409a6fe6 100644 --- a/src/libpriv/rpmostree-kernel.c +++ b/src/libpriv/rpmostree-kernel.c @@ -38,6 +38,7 @@ #include "rpmostree-core.h" #include "rpmostree-kernel.h" #include "rpmostree-bwrap.h" +#include "rpmostree-rust.h" #include "rpmostree-util.h" static const char usrlib_ostreeboot[] = "usr/lib/ostree-boot"; @@ -488,12 +489,15 @@ rpmostree_run_dracut (int rootfs_dfd, */ static const char rpmostree_dracut_wrapper_path[] = "usr/bin/rpmostree-dracut-wrapper"; /* This also hardcodes a few arguments */ - static const char rpmostree_dracut_wrapper[] = + g_autofree char * rpmostree_dracut_wrapper = + g_strdup_printf ( "#!/usr/bin/bash\n" "set -euo pipefail\n" + "export PATH=%s:${PATH}\n" "extra_argv=; if (dracut --help; true) | grep -q -e --reproducible; then extra_argv=\"--reproducible --gzip\"; fi\n" "mkdir -p /tmp/dracut && dracut $extra_argv -v --add ostree --tmpdir=/tmp/dracut -f /tmp/initramfs.img \"$@\"\n" - "cat /tmp/initramfs.img >/proc/self/fd/3\n"; + "cat /tmp/initramfs.img >/proc/self/fd/3\n", + ror_cliwrap_destdir ()); g_autoptr(RpmOstreeBwrap) bwrap = NULL; g_autoptr(GPtrArray) rebuild_argv = NULL; g_auto(GLnxTmpfile) tmpf = { 0, }; @@ -537,7 +541,7 @@ rpmostree_run_dracut (int rootfs_dfd, O_RDWR | O_CLOEXEC, &tmpf, error)) goto out; - if (glnx_loop_write (tmpf.fd, rpmostree_dracut_wrapper, sizeof (rpmostree_dracut_wrapper)) < 0 + if (glnx_loop_write (tmpf.fd, rpmostree_dracut_wrapper, strlen (rpmostree_dracut_wrapper)) < 0 || fchmod (tmpf.fd, 0755) < 0) { glnx_set_error_from_errno (error); diff --git a/tests/vmcheck/test-misc-1.sh b/tests/vmcheck/test-misc-1.sh index a89c4b2f37..c5f24eaf3a 100755 --- a/tests/vmcheck/test-misc-1.sh +++ b/tests/vmcheck/test-misc-1.sh @@ -86,6 +86,28 @@ fi assert_file_has_content err.txt 'ReloadConfig not allowed for user' echo "ok auth" +wrapdir="/usr/libexec/rpm-ostree/wrapped" +if [ -d "${wrapdir}" ]; then + # Test wrapped functions for rpm + rpm --version + rpm -qa > /dev/null + rpm --verify >out.txt + assert_file_has_content out.txt "rpm --verify is not necessary for ostree-based systems" + rm -f out.txt + if rpm -e bash 2>out.txt; then + fatal "rpm -e worked" + fi + assert_file_has_content out.txt 'Dropping privileges as `rpm` was executed with not "known safe" arguments' + + if dracut --blah 2>out.txt; then + fatal "dracut worked" + fi + assert_file_has_content out.txt 'This system is rpm-ostree based' + rm -f out.txt +else + echo "Missing ${wrapdir}; cliwrap not enabled" +fi + # Test coreos-rootfs vm_shell_inline > coreos-rootfs.txt << EOF mkdir /var/tmp/coreos-rootfs