initial commit

This commit is contained in:
Dominic 2023-04-05 14:05:11 +02:00
commit ddc990b522
Signed by: msrd0
GPG key ID: DCC8C247452E98F9
13 changed files with 1679 additions and 0 deletions

23
src/main.rs Normal file
View file

@ -0,0 +1,23 @@
mod setup;
mod steps;
use crate::steps::{install_autostart, set_passwords};
use setup::Setup;
use steps::{init_first_boot, init_network, init_os, install_packages, prepare_img};
const IMAGE_SIZE_GB: u64 = 2;
fn main() -> anyhow::Result<()> {
let setup = Setup::load()?;
let img = prepare_img()?;
init_os(&setup, &img)?;
init_network(&img, &setup)?;
install_packages(&img, &setup)?;
install_autostart(&img, &setup)?;
init_first_boot(&img)?;
set_passwords(&img, &setup)?;
eprintln!("SUCCESS");
Ok(())
}

View file

@ -0,0 +1,47 @@
use super::{run_chroot, Image};
use std::fs;
pub fn init_first_boot(img: &Image) -> anyhow::Result<()> {
eprintln!("Preparing first boot script ...");
fs::write(
img.root.0.join("usr").join("local").join("bin").join("first-boot"),
r#"#!/bin/sh
set -xe
cat <<PARTED | sudo parted ---pretend-input-tty /dev/mmcblk0
unit %
resizepart 2
Yes
100%
PARTED
partprobe
resize2fs /dev/mmcblk0p2
rc-update add zram-init boot
rc-update del first-boot
rm /etc/init.d/first-boot /usr/local/bin/first-boot
reboot
"#
)?;
fs::write(
img.root.0.join("etc").join("init.d").join("first-boot"),
r#"#!/sbin/openrc-run
command="/usr/local/bin/first-boot"
command_background=false
depend() {
after modules
need localmount
}"#
)?;
run_chroot(
img,
"root",
"chmod +x /etc/init.d/first-boot /usr/local/bin/first-boot; rc-update add first-boot"
)?;
Ok(())
}

111
src/steps/init_network.rs Normal file
View file

@ -0,0 +1,111 @@
use super::{run_chroot, Image};
use crate::setup::{network::WifiSecurity, Setup};
use anyhow::Context as _;
use std::{
fs::{self, File},
io::Write as _
};
pub fn init_network(img: &Image, setup: &Setup) -> anyhow::Result<()> {
eprintln!("Preparing network ...");
let mut interfaces = File::create(img.root.0.join("etc").join("network").join("interfaces"))?;
for (name, ty) in [("lo", "loopback"), ("eth0", "dhcp")] {
writeln!(interfaces, "auto {name}")?;
writeln!(interfaces, "iface {name} inet {ty}")?;
writeln!(interfaces)?;
}
writeln!(interfaces, "hostname {}", setup.os.host.hostname)?;
fs::write(
img.root.0.join("etc").join("resolv.conf"),
r#"nameserver 1.0.0.1
nameserver 1.1.1.1
nameserver 2606:4700:4700::1001
nameserver 2606:4700:4700::1111
"#
)?;
run_chroot(img, "root", ["rc-update", "add", "networking", "default"])?;
if setup.network.tailscale {
run_chroot(img, "root", "apk add tailscale; rc-update add tailscale default")?;
fs::write(
img.root.0.join("usr").join("local").join("bin").join("tailscale-up"),
r#"#!/bin/sh
for i in 0 1 2 3 4; do
ping -c 2 -W 1 1.1.1.1 && break
if [ "$i" == "4" ]; then exit 1; fi
echo "Waiting for network ..."
sleep 2s
done
set -e
echo "Connecting to tailscale ..."
timeout -v 2m tailscale up
"#
)?;
fs::write(
img.root.0.join("etc").join("init.d").join("tailscale-up"),
r#"#!/sbin/openrc-run
command="/usr/local/bin/tailscale-up"
command_background=false
depend() {
after ntpd
need tailscale
}"#
)?;
run_chroot(
img,
"root",
"chmod +x /usr/local/bin/tailscale-up /etc/init.d/tailscale-up; rc-update add tailscale-up default"
)?;
}
if !setup.network.wifi.is_empty() {
run_chroot(img, "root", "echo 'brcmfmac' >> /etc/modules")?;
run_chroot(img, "root", ["apk", "add", "iw", "iwd", "wireless-tools", "wireless-regdb"])?;
run_chroot(img, "root", ["rc-update", "add", "iwd", "default"])?;
let iwd = img.root.0.join("etc").join("iwd");
fs::create_dir_all(&iwd).context("Unable to create /etc/iwd directory")?;
fs::write(
iwd.join("main.conf"),
r#"# main iwd configuration file
[General]
EnableNetworkConfiguration=true
[Network]
EnableIPv6=true
NameResolvingService=none
"#
)
.context("Unable to write /etc/iwd/main.conf")?;
let iwd = img.root.0.join("var").join("lib").join("iwd");
fs::create_dir_all(&iwd).context("Unable to create /var/lib/iwd directory")?;
for wifi in &setup.network.wifi {
let ssid = &wifi.ssid;
match &wifi.security {
WifiSecurity::None => {
fs::write(iwd.join(format!("{ssid}.open")), "\n")?;
},
WifiSecurity::WpaPsk { password } => {
fs::write(iwd.join(format!("{ssid}.psk")), format!("[Security]\nPassphrase={password}\n"))?;
},
WifiSecurity::WpaEap { identity, password } => {
let mut file = File::create(iwd.join(format!("{ssid}.8021x")))?;
writeln!(file, "[Security]")?;
writeln!(file, "EAP-Method=PWD")?;
writeln!(file, "EAP-Identity={identity}")?;
writeln!(file, "EAP-Password={password}")?;
writeln!(file)?;
writeln!(file, "[Settings]")?;
writeln!(file, "AutoConnect=true")?;
}
}
}
}
Ok(())
}

189
src/steps/init_os.rs Normal file
View file

@ -0,0 +1,189 @@
use super::{download, run, run_chroot, tempfile, Image};
use crate::setup::Setup;
use anyhow::Context as _;
use std::{
fs::{self, File},
io::Write as _,
path::Path
};
pub fn init_os(setup: &Setup, img: &Image) -> anyhow::Result<()> {
eprintln!("Preparing operating system ...");
// write raspberry pi config
fs::write(img.boot.0.join("cmdline.txt"), &setup.os.rpi.cmdline)?;
let mut config = File::create(img.boot.0.join("config.txt"))?;
writeln!(
config,
r#"# Do not modify this file. Create and/or modify usercfg.txt instead.
[pi3]
kernel=vmlinuz-rpi
initramfs initramfs-rpi
[pi3+]
kernel=vmlinuz-rpi
initramfs initramfs-rpi
[pi4]
enable_gic=1
kernel=vmlinuz-rpi4
initramfs initramfs-rpi4
[all]"#
)?;
if setup.os.alpine.arch.is64bit() {
writeln!(config, "arm_64bit=1")?;
}
writeln!(config, "include usercfg.txt")?;
drop(config);
fs::write(img.boot.0.join("usercfg.txt"), &setup.os.rpi.usercfg)?;
// write initial apk config
let etc = img.root.0.join("etc");
let etc_apk = etc.join("apk");
let etc_apk_keys = etc_apk.join("keys");
fs::create_dir_all(&etc_apk_keys).context("Failed to create /etc/apk/keys directory")?;
let mut repositories = File::create(etc_apk.join("repositories"))?;
for repo in ["main", "community"] {
writeln!(repositories, "{}/{}/{repo}", setup.os.alpine.mirror, setup.os.alpine.branch)?;
}
for repo in &setup.os.alpine.extra_repos {
writeln!(repositories, "{repo}")?;
}
drop(repositories);
// download apk keys
let keys = tempfile()?;
download(
keys.path(),
&format!(
"https://gitlab.alpinelinux.org/alpine/aports/-/archive/{branch}/aports-{branch}.tar.bz2?path=main/alpine-keys",
branch = setup.os.alpine.branch.git_branch()
)
)?;
run(&[
"tar",
"xfj",
keys.path().to_str().unwrap(),
"-C",
etc_apk_keys.to_str().unwrap(),
"--strip-components=3",
"--wildcards",
"*/alpine-devel@lists.alpinelinux.org-*.rsa.pub"
])?;
drop(keys);
// copy additional keys
for key in &setup.os.alpine.extra_keys {
let key = Path::new(key);
let name = key.file_name().unwrap();
fs::copy(Path::new("setup").join(key), etc_apk_keys.join(name))?;
}
// bootstrap alpine
run(&[
"apk",
"add",
"--root",
&img.root,
"--update-cache",
"--initdb",
"--arch",
setup.os.alpine.arch.to_str(),
"agetty",
"alpine-base",
"alpine-sdk",
"ca-certificates",
"elogind",
"eudev",
"haveged",
"linux-rpi",
"linux-rpi4",
"openssh",
"parted",
"raspberrypi-bootloader",
"sudo",
"tzdata",
"udev-init-scripts",
"util-linux-login",
"zram-init"
])?;
run(&[
"sed",
"-E",
"-e",
"s,getty(.*)$,agetty --noclear\\1 linux,",
"-i",
etc.join("inittab").to_str().unwrap()
])?;
for service in ["udev", "udev-postmount", "udev-settle", "udev-trigger"] {
run_chroot(img, "root", ["rc-update", "add", service, "sysinit"])?;
}
for service in ["modules", "syslog", "swclock"] {
run_chroot(img, "root", ["rc-update", "add", service, "boot"])?;
}
for service in ["elogind", "haveged", "ntpd", "sshd"] {
run_chroot(img, "root", ["rc-update", "add", service, "default"])?;
}
// configure fstab
fs::write(
etc.join("fstab"),
r#"# <device> <dir> <type> <options> <dump> <fsck>
/dev/mmcblk0p1 /boot vfat defaults 0 2
/dev/mmcblk0p2 / ext4 defaults,noatime 0 1
"#
)?;
// something is broken with abuild's functions.sh, so let's set CBUILD ourselves
let profile_d = etc.join("profile.d");
fs::create_dir_all(&profile_d)?;
fs::write(
profile_d.join("cbuild.sh"),
&format!("export CBUILD={}", setup.os.alpine.arch.cbuild())
)?;
// set up the locale
run_chroot(img, "root", ["setup-hostname", &setup.os.host.hostname])?;
run_chroot(img, "root", ["setup-timezone", "-z", &setup.os.host.timezone])?;
if let Some(variant) = setup.os.host.keymap_variant.as_deref() {
run_chroot(img, "root", ["setup-keymap", &setup.os.host.keymap, variant])?;
} else {
run_chroot(img, "root", ["setup-keymap", &setup.os.host.keymap])?;
}
// set up a user that we can use to install packages
run_chroot(img, "root", ["adduser", "-D", "mkalpiimg"])?;
run_chroot(img, "root", ["adduser", "mkalpiimg", "abuild"])?;
run_chroot(img, "root", "echo mkalpiimg:mkalpiimg | chpasswd")?;
fs::write(etc.join("sudoers.d").join("mkalpiimg"), "%abuild ALL=(ALL) NOPASSWD: ALL")?;
fs::create_dir_all(img.root.0.join("var").join("cache").join("distfiles"))?;
run_chroot(img, "root", "chgrp abuild /var/cache/distfiles; chmod 775 /var/cache/distfiles")?;
run_chroot(img, "mkalpiimg", ["abuild-keygen", "-a", "-n", "-b", "4096"])?;
run_chroot(img, "root", "cp /home/mkalpiimg/.abuild/*.rsa.pub /etc/apk/keys/")?;
// avoid a bug with cargo in qemu: https://github.com/rust-lang/cargo/issues/10583
let cargo = img.root.0.join("home").join("mkalpiimg").join(".cargo");
fs::create_dir_all(&cargo)?;
fs::write(cargo.join("config.toml"), "[net]\ngit-fetch-with-cli = true\n")?;
// set up the other users as requested
fs::write(etc.join("sudoers.d").join("wheel"), "%wheel ALL=(ALL:ALL) ALL")?;
for user in &setup.os.user {
run_chroot(img, "root", ["adduser", "-s", "/bin/ash", "-D", &user.name])?;
if user.sudo {
run_chroot(img, "root", ["adduser", &user.name, "wheel"])?;
}
for grp in &user.groups {
run_chroot(img, "root", ["adduser", &user.name, grp])?;
}
let ssh = img.root.0.join("home").join(&user.name).join(".ssh");
fs::create_dir_all(&ssh)?;
let mut keys = File::create(ssh.join("authorized_keys"))?;
for key in &user.authorized_keys {
writeln!(keys, "{key}")?;
}
drop(keys);
run_chroot(img, "root", ["chown", "-R", &user.name, &format!("/home/{}/.ssh", user.name)])?;
run_chroot(img, &user.name, "chmod 700 ~/.ssh; chmod 600 ~/.ssh/*")?;
}
Ok(())
}

View file

@ -0,0 +1,52 @@
use super::{run_chroot, Image};
use crate::setup::Setup;
pub fn install_packages(img: &Image, setup: &Setup) -> anyhow::Result<()> {
eprintln!("Installing packages ...");
for pkg in &setup.packages.install {
run_chroot(img, "root", ["apk", "add", pkg])?;
}
for service in &setup.packages.autostart {
run_chroot(img, "root", ["rc-update", "add", service, "default"])?;
}
Ok(())
}
pub fn install_autostart(img: &Image, setup: &Setup) -> anyhow::Result<()> {
// TODO make the kiosk optional maybe?
let kiosk = &setup.autostart.kiosk;
eprintln!("Installing kiosk ...");
run_chroot(img, "root", [
"apk",
"add",
"cage",
"firefox-esr",
"font-noto",
"mesa-dri-gallium",
"polkit",
"polkit-elogind"
])?;
run_chroot(img, "root", [
"sed",
"-E",
"-i",
"-e",
&format!("/tty1/s,agetty,agetty --autologin {},", kiosk.user),
"/etc/inittab"
])?;
run_chroot(img, &kiosk.user, "[ -e ~/.profile ] || echo '#!/bin/busybox ash' >~/.profile")?;
run_chroot(
img,
&kiosk.user,
format!(
r#"echo 'if [ "$(tty)" == "/dev/tty1" ]; then dbus-run-session cage firefox-esr --kiosk "{}"; fi' >>~/.profile"#,
kiosk.page
)
.as_str()
)?;
Ok(())
}

183
src/steps/mod.rs Normal file
View file

@ -0,0 +1,183 @@
use anyhow::{anyhow, bail, Context as _};
use std::{
fmt::Write as _,
fs::File,
io::{self, BufWriter},
ops::Deref,
os::unix::process::ExitStatusExt as _,
path::{Path, PathBuf},
process::Command
};
use tempfile::{NamedTempFile, TempDir};
fn tempfile() -> io::Result<NamedTempFile> {
NamedTempFile::new()
}
fn run(cmd: &[&str]) -> anyhow::Result<()> {
let display = format!("`\"{}\"`", cmd.join("\" \""));
eprintln!(" -> Running {display} ...");
let status = match Command::new(cmd[0]).args(cmd.iter().skip(1)).status() {
Ok(status) => status,
Err(err) => bail!("Failed to run `\"{}\"`: {err}", cmd.join("\" \""))
};
if !status.success() {
bail!(
"Failed to run `\"{}\"`: Process returned exit code {}",
cmd.join("\" \""),
status.into_raw()
);
}
Ok(())
}
fn run_output(cmd: &[&str]) -> anyhow::Result<String> {
let display = format!("`\"{}\"`", cmd.join("\" \""));
eprintln!(" -> Running {display} ...");
let output = match Command::new(cmd[0]).args(cmd.iter().skip(1)).output() {
Ok(output) => output,
Err(err) => bail!("Failed to run {display}: {err}")
};
if !output.status.success() {
bail!("Failed to run {display}: Process returned exit code {}", output.status.into_raw());
}
String::from_utf8(output.stdout).context("The output of {display} contained invalid characters")
}
macro_rules! run_parted {
($path:expr, $($arg:expr),+) => {
run(&["parted", "-s", $path.to_str().unwrap(), $($arg,)+])
};
}
trait ChrootCmd {
fn into_shell_cmd(self) -> String;
}
impl<const N: usize> ChrootCmd for [&str; N] {
fn into_shell_cmd(self) -> String {
(&self[..]).into_shell_cmd()
}
}
impl ChrootCmd for &[&str] {
fn into_shell_cmd(self) -> String {
let mut cmd = String::new();
for arg in self {
write!(cmd, "'{}' ", arg.replace('\'', "'\"'\"'")).unwrap();
}
cmd
}
}
impl ChrootCmd for &str {
fn into_shell_cmd(self) -> String {
self.into()
}
}
fn run_chroot(img: &Image, user: &str, cmd: impl ChrootCmd) -> anyhow::Result<()> {
let cmd = format!(". /etc/profile; set -euo pipefail; {}", cmd.into_shell_cmd());
run(&[
"chroot",
&img.root,
"/usr/bin/env",
"-i",
"sudo",
"-u",
user,
"-i",
"-n",
"ash",
"-c",
&cmd
])
}
fn download(out: &Path, url: &str) -> anyhow::Result<()> {
eprintln!(" -> Downloading {url} ...");
attohttpc::get(url).send()?.write_to(BufWriter::new(File::create(out)?))?;
Ok(())
}
pub struct Image {
path: PathBuf,
boot: Mount,
dev: Mount,
root: Mount,
device: LoopDevice
}
impl Drop for Image {
fn drop(&mut self) {
eprintln!("Cleaning up ...");
}
}
struct LoopDevice(String);
impl LoopDevice {
fn part(&self, num: usize) -> String {
format!("{}p{num}", self.0)
}
}
impl Deref for LoopDevice {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for LoopDevice {
fn drop(&mut self) {
let res = run(&["udisksctl", "loop-delete", "--no-user-interaction", "-b", self]);
if let Err(err) = res {
eprintln!("Failed to free loop device {}: {err}", self.0);
}
}
}
struct Mount(PathBuf);
impl Mount {
fn create(device: &str) -> anyhow::Result<Self> {
let path = TempDir::new()?.into_path();
run(&["mount", device, &path.to_string_lossy()])?;
Ok(Self(path))
}
}
impl Deref for Mount {
type Target = str;
fn deref(&self) -> &Self::Target {
self.0.to_str().unwrap()
}
}
impl Drop for Mount {
fn drop(&mut self) {
let res = run(&["umount", self]);
if let Err(err) = res {
eprintln!("Failed to unmount {}: {err}", self.0.display());
}
}
}
mod init_first_boot;
mod init_network;
mod init_os;
mod install_packages;
mod prepare_img;
mod set_passwords;
pub use init_first_boot::init_first_boot;
pub use init_network::init_network;
pub use init_os::init_os;
pub use install_packages::{install_autostart, install_packages};
pub use prepare_img::prepare_img;
pub use set_passwords::set_passwords;

57
src/steps/prepare_img.rs Normal file
View file

@ -0,0 +1,57 @@
use super::{run, run_output, Image, LoopDevice, Mount};
use crate::IMAGE_SIZE_GB;
use anyhow::{anyhow, Context as _};
use std::{
fs::{self, File},
path::PathBuf
};
pub fn prepare_img() -> anyhow::Result<Image> {
eprintln!("Preparing empty image ...");
// let's create a file with the correct size
let path = PathBuf::from("alpi.img");
let file = File::create(&path).context("Failed to create alpi.img")?;
file.set_len(IMAGE_SIZE_GB * 1024 * 1024 * 1024)?;
drop(file);
// lets get the thing partitioned
let size = format!("{IMAGE_SIZE_GB}G");
run_parted!(path, "mktable", "msdos")?;
run_parted!(path, "mkpart", "primary", "fat32", "0G", "128M")?;
run_parted!(path, "mkpart", "primary", "ext4", "128M", &size)?;
// create a loopback device
let output = run_output(&["udisksctl", "loop-setup", "--no-user-interaction", "-f", path.to_str().unwrap()])?;
print!("{output}");
let idx = output.find("/dev/loop").ok_or_else(|| anyhow!("Unable to identify loop device"))?;
let mut end = idx + "/dev/loop".len();
while (output.as_bytes()[end] as char).is_ascii_digit() {
end += 1;
}
let device = LoopDevice(output[idx .. end].to_owned());
// TODO: Somehow create the file systems without root
run(&["mkfs.fat", "-n", "ALPI-BOOT", "-F", "32", &device.part(1)])?;
run(&["mkfs.ext4", "-q", "-F", "-L", "ALPI", "-O", "^has_journal", &device.part(2)])?;
run_parted!(path, "p")?;
// mount everything
let root = Mount::create(&device.part(2))?;
let boot = root.0.join("boot");
fs::create_dir_all(&boot)?;
run(&["mount", &device.part(1), boot.to_str().unwrap()])?;
let boot = Mount(boot);
let dev = root.0.join("dev");
fs::create_dir_all(&dev)?;
run(&["mount", "udev", dev.to_str().unwrap(), "-t", "devtmpfs", "-o", "mode=0755,nosuid"])?;
let dev = Mount(dev);
Ok(Image {
path,
device,
boot,
dev,
root
})
}

View file

@ -0,0 +1,21 @@
use super::{run_chroot, Image};
use crate::setup::Setup;
use anyhow::bail;
pub fn set_passwords(img: &Image, setup: &Setup) -> anyhow::Result<()> {
eprintln!("Setting user passwords ...");
// the password is insecure by design, so we'll lock it for security
run_chroot(img, "root", ["passwd", "-l", "mkalpiimg"])?;
for user in &setup.os.user {
if let Some(password) = &user.password {
if password.contains('\'') {
bail!("Sorry I won't set passwords containing `'`");
}
run_chroot(img, "root", format!("echo '{}:{password}' | chpasswd", user.name).as_str())?;
}
}
Ok(())
}