345 lines
9.9 KiB
Rust
345 lines
9.9 KiB
Rust
use eyre::{format_err, Result};
|
|
use log::{info, warn};
|
|
use tokio::{
|
|
fs,
|
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
|
};
|
|
|
|
use dkl::{
|
|
self,
|
|
apply::{self, chroot, set_perms},
|
|
bootstrap::Config,
|
|
};
|
|
|
|
use super::{exec, mount, retry, retry_or_ignore, try_exec};
|
|
use crate::utils;
|
|
|
|
pub async fn bootstrap(cfg: Config) {
|
|
let verifier = retry(async || Verifier::from_config(&cfg)).await;
|
|
let bs = cfg.bootstrap;
|
|
|
|
mount(Some(&bs.dev), "/bootstrap", "ext4", None).await;
|
|
|
|
let boot_version = utils::param("version").unwrap_or("current");
|
|
let base_dir = &format!("/bootstrap/{boot_version}");
|
|
|
|
retry_or_ignore(async || {
|
|
if !fs::try_exists(&base_dir).await? {
|
|
info!("creating {base_dir}");
|
|
fs::create_dir_all(&base_dir).await?
|
|
}
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
let sys_cfg: dkl::Config = retry(async || {
|
|
let sys_cfg_bytes = seed_config(base_dir, &bs.seed, &verifier).await?;
|
|
Ok(serde_yaml::from_slice(&sys_cfg_bytes)?)
|
|
})
|
|
.await;
|
|
|
|
mount_system(&sys_cfg, base_dir, &verifier).await;
|
|
|
|
retry_or_ignore(async || {
|
|
let path = "/etc/resolv.conf";
|
|
if fs::try_exists(path).await? {
|
|
info!("cp /etc/resolv.conf");
|
|
fs::copy(path, &format!("/system{path}")).await?;
|
|
}
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
retry_or_ignore(async || apply::files(&sys_cfg.files, "/system").await).await;
|
|
|
|
apply_groups(&sys_cfg.groups, "/system").await;
|
|
apply_users(&sys_cfg.users, "/system").await;
|
|
|
|
// TODO VPNs
|
|
|
|
mount_filesystems(&sys_cfg.mounts, "/system").await;
|
|
|
|
retry_or_ignore(async || {
|
|
info!("setting up root user");
|
|
setup_root_user(&sys_cfg.root_user, "/system").await
|
|
})
|
|
.await;
|
|
|
|
exec("chroot", &["/system", "update-ca-certificates"]).await
|
|
}
|
|
|
|
struct Verifier {
|
|
pubkey: Option<Vec<u8>>,
|
|
}
|
|
impl Verifier {
|
|
fn from_config(cfg: &Config) -> Result<Self> {
|
|
let Some(ref pubkey) = cfg.signer_public_key else {
|
|
return Ok(Self { pubkey: None });
|
|
};
|
|
|
|
use base64::{prelude::BASE64_STANDARD, Engine};
|
|
let pubkey = BASE64_STANDARD.decode(pubkey)?;
|
|
let pubkey = Some(pubkey);
|
|
|
|
return Ok(Self { pubkey });
|
|
}
|
|
|
|
async fn verify_path(&self, path: &str) -> Result<Vec<u8>> {
|
|
let data = (fs::read(path).await).map_err(|e| format_err!("failed to read {path}: {e}"))?;
|
|
|
|
let Some(ref pubkey) = self.pubkey else {
|
|
return Ok(data);
|
|
};
|
|
|
|
info!("verifying {path}");
|
|
|
|
let sig = &format!("{path}.sig");
|
|
let sig = (fs::read(sig).await).map_err(|e| format_err!("failed to read {sig}: {e}"))?;
|
|
|
|
use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier};
|
|
let pubkey = PKey::public_key_from_der(pubkey)?;
|
|
|
|
let sig_ok = Verifier::new(MessageDigest::sha512(), &pubkey)?
|
|
.verify_oneshot(&sig, &data)
|
|
.map_err(|e| format_err!("verify failed: {e}"))?;
|
|
|
|
if sig_ok {
|
|
Ok(data)
|
|
} else {
|
|
Err(format_err!("signature verification failed for {path}"))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn seed_config(
|
|
base_dir: &str,
|
|
seed_url: &Option<String>,
|
|
verifier: &Verifier,
|
|
) -> Result<Vec<u8>> {
|
|
let cfg_path = &format!("{base_dir}/config.yaml");
|
|
|
|
if fs::try_exists(cfg_path).await? {
|
|
return Ok(fs::read(cfg_path).await?);
|
|
}
|
|
|
|
let bs_tar = "/bootstrap.tar";
|
|
if !fs::try_exists(bs_tar).await? {
|
|
if let Some(seed_url) = seed_url.as_ref() {
|
|
fetch_bootstrap(seed_url, bs_tar).await?;
|
|
} else {
|
|
return Err(format_err!(
|
|
"no {cfg_path}, no {bs_tar} and no seed, can't bootstrap"
|
|
));
|
|
}
|
|
}
|
|
|
|
try_exec("tar", &["xf", bs_tar, "-C", base_dir]).await?;
|
|
|
|
if !fs::try_exists(cfg_path).await? {
|
|
return Err(format_err!("{cfg_path} does not exist after seeding"));
|
|
}
|
|
|
|
verifier.verify_path(&cfg_path).await
|
|
}
|
|
|
|
async fn fetch_bootstrap(seed_url: &str, output_file: &str) -> Result<()> {
|
|
let tmp_file = &format!("{output_file}.new");
|
|
let _ = fs::remove_file(tmp_file).await;
|
|
try_exec("wget", &["-O", tmp_file, seed_url]).await?;
|
|
|
|
fs::rename(tmp_file, output_file)
|
|
.await
|
|
.map_err(|e| format_err!("seed rename failed: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn default_root_tmpfs_opts() -> Option<String> {
|
|
let mem = sys_info::mem_info()
|
|
.inspect_err(|e| warn!("failed to get system memory info, using default tmpfs size: {e}"))
|
|
.ok()?;
|
|
|
|
let mem_size = mem.total /* kiB */ / 1024;
|
|
let fs_size = 1024.min(mem_size / 2);
|
|
info!("system has {mem_size} MiB of memory, allowing {fs_size} MiB for root tmpfs");
|
|
Some(format!("size={fs_size}m"))
|
|
}
|
|
|
|
async fn mount_system(cfg: &dkl::Config, bs_dir: &str, verifier: &Verifier) {
|
|
let opts = match utils::param("root-opts") {
|
|
Some(s) => Some(s.to_string()),
|
|
None => default_root_tmpfs_opts(),
|
|
};
|
|
|
|
let mem_dir = "/mem";
|
|
mount(None, mem_dir, "tmpfs", opts.as_deref()).await;
|
|
|
|
let layers_dir = &format!("{mem_dir}/layers");
|
|
let mut lower_dir = String::new();
|
|
|
|
for layer in &cfg.layers {
|
|
let src = retry(async || {
|
|
if layer == "modules" {
|
|
(fs::read("/modules.sqfs").await)
|
|
.map_err(|e| format_err!("read /modules.sqfs failed: {e}"))
|
|
} else {
|
|
verifier.verify_path(&format!("{bs_dir}/{layer}.fs")).await
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let tgt = &format!("{mem_dir}/{layer}.fs");
|
|
retry(async || {
|
|
info!("copying layer {layer}");
|
|
|
|
let mut out = (fs::File::create(tgt).await)
|
|
.map_err(|e| format_err!("create {tgt} failed: {e}"))?;
|
|
(out.write_all(&src).await).map_err(|e| format_err!("write failed: {e}"))?;
|
|
(out.flush().await).map_err(|e| format_err!("write failed: {e}"))
|
|
})
|
|
.await;
|
|
|
|
let layer_dir = &format!("{layers_dir}/{layer}");
|
|
mount(Some(tgt), layer_dir, "squashfs", None).await;
|
|
|
|
if !lower_dir.is_empty() {
|
|
lower_dir.push(':');
|
|
}
|
|
lower_dir.push_str(&layer_dir);
|
|
}
|
|
|
|
let upper_dir = &format!("{mem_dir}/upper");
|
|
let work_dir = &format!("{mem_dir}/work");
|
|
|
|
retry_or_ignore(async || {
|
|
fs::create_dir_all(upper_dir).await?;
|
|
fs::create_dir_all(work_dir).await?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
let opts = format!("lowerdir={lower_dir},upperdir={upper_dir},workdir={work_dir}");
|
|
mount(None, "/system", "overlay", Some(&opts)).await;
|
|
|
|
// make root rshared (default in systemd, required by Kubernetes 1.10+)
|
|
// equivalent to "mount --make-rshared /"
|
|
// see kernel's Documentation/sharedsubtree.txt (search rshared)
|
|
retry_or_ignore(async || {
|
|
use nix::mount::MsFlags as M;
|
|
const NONE: Option<&str> = None;
|
|
nix::mount::mount(NONE, "/system", NONE, M::MS_SHARED | M::MS_REC, NONE)?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn apply_groups(groups: &[dkl::Group], root: &str) {
|
|
for group in groups {
|
|
let mut args = vec![root, "groupadd", "-r"];
|
|
|
|
let gid = group.gid.map(|s| s.to_string());
|
|
if let Some(gid) = gid.as_ref() {
|
|
args.extend(&["-g", gid]);
|
|
}
|
|
|
|
args.push(group.name.as_str());
|
|
|
|
exec("chroot", &args).await;
|
|
}
|
|
}
|
|
|
|
async fn apply_users(users: &[dkl::User], root: &str) {
|
|
for user in users {
|
|
let mut args = vec![root, "useradd", "-r"];
|
|
|
|
let uid = user.uid.map(|s| s.to_string());
|
|
if let Some(uid) = uid.as_ref() {
|
|
args.extend(&["-u", uid]);
|
|
}
|
|
let gid = user.gid.map(|s| s.to_string());
|
|
if let Some(gid) = gid.as_ref() {
|
|
args.extend(&["-g", gid]);
|
|
}
|
|
|
|
args.push(user.name.as_str());
|
|
|
|
exec("chroot", &args).await;
|
|
}
|
|
}
|
|
|
|
async fn mount_filesystems(mounts: &[dkl::Mount], root: &str) {
|
|
for m in mounts {
|
|
let path = chroot(root, &m.path);
|
|
|
|
mount(
|
|
Some(&m.dev),
|
|
&path,
|
|
(m.r#type.as_deref())
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or("ext4"),
|
|
m.options.as_deref().filter(|v| !v.is_empty()),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn setup_root_user(user: &dkl::RootUser, root: &str) -> Result<()> {
|
|
if let Some(pw_hash) = user.password_hash.as_ref().filter(|v| !v.is_empty()) {
|
|
set_user_password("root", &pw_hash, root).await?;
|
|
}
|
|
|
|
let mut authorized_keys = Vec::new();
|
|
for ak in &user.authorized_keys {
|
|
authorized_keys.extend(ak.as_bytes());
|
|
authorized_keys.push(b'\n');
|
|
}
|
|
|
|
let ssh_dir = &chroot(root, "root/.ssh");
|
|
fs::create_dir_all(ssh_dir)
|
|
.await
|
|
.map_err(|e| format_err!("mkdir -p {ssh_dir} failed: {e}"))?;
|
|
set_perms(ssh_dir, Some(0o700))
|
|
.await
|
|
.map_err(|e| format_err!("chmod {ssh_dir} failed: {e}"))?;
|
|
|
|
let ak_path = &format!("{ssh_dir}/authorized_keys");
|
|
fs::write(ak_path, authorized_keys)
|
|
.await
|
|
.map_err(|e| format_err!("write {ak_path} failed: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn set_user_password(user: &str, password_hash: &str, root: &str) -> Result<()> {
|
|
info!("setting password for {user}");
|
|
|
|
let user = user.as_bytes();
|
|
let password_hash = password_hash.as_bytes();
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
let pw_file = &chroot(root, "etc/shadow");
|
|
let rd = fs::File::open(pw_file)
|
|
.await
|
|
.map_err(|e| format_err!("open {pw_file} failed: {e}"))?;
|
|
let mut rd = BufReader::new(rd);
|
|
|
|
let mut line = Vec::new();
|
|
while (rd.read_until(b'\n', &mut line).await)
|
|
.map_err(|e| format_err!("read {pw_file} failed: {e}"))?
|
|
!= 0
|
|
{
|
|
let mut split: Vec<_> = line.split(|c| *c == b':').collect();
|
|
if split.len() > 2 && split[0] == user {
|
|
split[1] = password_hash;
|
|
buf.extend(split.join(&b':'));
|
|
} else {
|
|
buf.extend(&line);
|
|
}
|
|
line.clear();
|
|
}
|
|
|
|
fs::write(pw_file, buf).await?;
|
|
Ok(())
|
|
}
|