diff --git a/.dockerignore b/.dockerignore index e7bf1da..cd39ab4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ Dockerfile +modd.* tmp/**/* dist/* go.work @@ -6,3 +7,4 @@ go.work.sum modd.*conf test-initrd* test-initrd/**/* +target diff --git a/.gitignore b/.gitignore index 008db5b..636c13c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /tmp /go.work /go.work.sum +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7b28368 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,634 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "init" +version = "2.1.0" +dependencies = [ + "env_logger", + "eyre", + "itertools", + "libc", + "log", + "nix", + "regex", + "serde", + "serde_json", + "serde_yaml", + "shell-escape", + "termios", + "tokio", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.173" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "syn" +version = "2.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..63602ae --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "init" +version = "2.1.0" +edition = "2024" + +[profile.release] +strip = true +panic = "abort" +opt-level = "z" +lto = true +codegen-units = 1 + +[dependencies] +libc = { version = "0.2", default-features = false } +env_logger = "0.11.3" +eyre = "0.6.12" +itertools = "0.14.0" +log = "0.4.21" +nix = { version = "0.30.1", features = ["feature", "mount", "process", "reboot"] } +regex = "1.11.1" +serde = { version = "1.0.198", features = ["derive"] } +serde_json = "1.0.116" +serde_yaml = "0.9.34" +shell-escape = "0.1.5" +tokio = { version = "1.38.0", features = ["rt", "net", "fs", "process", "io-std", "io-util", "sync", "macros", "signal"] } +termios = "0.3.3" diff --git a/Dockerfile b/Dockerfile index 0881909..ddde9bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,37 @@ -from golang:1.23.2-alpine3.20 as build -run apk add --no-cache gcc musl-dev linux-headers eudev-dev upx +from rust:1.87.0-alpine as rust + +run apk add --no-cache git musl-dev libudev-zero-dev # pkgconfig cryptsetup-dev lvm2-dev clang-dev clang-static workdir /src - copy . . - -env CGO_ENABLED=1 -run \ - --mount=type=cache,id=gomod,target=/go/pkg/mod \ - --mount=type=cache,id=gobuild,target=/root/.cache/go-build \ - go test ./... \ -&& go build -ldflags "-s -w" -o /go/bin/init -trimpath . - -run upx /go/bin/init +run --mount=type=cache,id=novit-rs,target=/usr/local/cargo/registry \ + --mount=type=cache,id=novit-rs-target,sharing=private,target=/src/target \ + cargo build --release && cp target/release/init / # ------------------------------------------------------------------------ -from alpine:3.20.3 as initrd - -run apk add --no-cache xz +from alpine:3.22.0 as initrd workdir /layer + run . /etc/os-release \ && wget -O- https://dl-cdn.alpinelinux.org/alpine/v${VERSION_ID%.*}/releases/x86_64/alpine-minirootfs-${VERSION_ID}-x86_64.tar.gz |tar zxv -run apk add --no-cache -p . musl lvm2 lvm2-extra lvm2-dmeventd udev cryptsetup e2fsprogs lsblk -run rm -rf usr/share/apk var/cache/apk +run apk add --no-cache --update -p . musl coreutils \ + lvm2 lvm2-extra lvm2-dmeventd udev cryptsetup \ + e2fsprogs lsblk openssh-server \ + && rm -rf usr/share/apk var/cache/apk etc/motd -copy --from=build /go/bin/init . +copy etc/sshd_config etc/ssh/sshd_config + +copy --from=rust /init init +run cd bin && for cmd in init-version init-connect bootstrap; do ln -s ../init $cmd; done # check viability -run chroot /layer /init hello +run chroot . init-version run find |cpio -H newc -o >/initrd # ------------------------------------------------------------------------ -from alpine:3.20.3 +from alpine:3.22.0 copy --from=initrd /initrd / entrypoint ["base64","/initrd"] diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..057ea2e --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,5 @@ +from rust:1.77.1-alpine as rust + +run apk add --no-cache musl-dev +run apk add --no-cache musl lvm2 lvm2-extra lvm2-dmeventd udev cryptsetup e2fsprogs btrfs-progs lsblk + diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..431e758 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,2 @@ +from alpine:3.19.0 +run apk add --no-cache musl lvm2 lvm2-extra lvm2-dmeventd udev cryptsetup e2fsprogs btrfs-progs lsblk diff --git a/build-init b/build-init new file mode 100755 index 0000000..ac56ce6 --- /dev/null +++ b/build-init @@ -0,0 +1,27 @@ +set -ex + +which podman &>/dev/null && docker=podman || docker=docker + +mkdir -p empty +$docker build -t nv-rs-build --network=host -f Dockerfile.build empty + +case $1 in + release) + opts=--release + bindir=target/release + ;; + "") + bindir=target/debug + ;; + *) + echo >&2 "invalid arg: $1" + exit 1 + ;; +esac + +$docker run --rm -i --net=host --user=$UID \ + nv-rs-build \ + cargo build $opts + +mkdir -p dist +cp $bindir/init dist/ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..2928225 --- /dev/null +++ b/build.rs @@ -0,0 +1,9 @@ +use std::process::Command; +fn main() { + let output = Command::new("git") + .args(&["rev-parse", "HEAD"]) + .output() + .unwrap(); + let git_commit = String::from_utf8(output.stdout).unwrap(); + println!("cargo:rustc-env=GIT_COMMIT={}", git_commit); +} diff --git a/debug/bootstrap b/debug/bootstrap new file mode 120000 index 0000000..2f5573a --- /dev/null +++ b/debug/bootstrap @@ -0,0 +1 @@ +../target/debug/init \ No newline at end of file diff --git a/debug/connect-boot b/debug/connect-boot new file mode 120000 index 0000000..2f5573a --- /dev/null +++ b/debug/connect-boot @@ -0,0 +1 @@ +../target/debug/init \ No newline at end of file diff --git a/debug/init b/debug/init new file mode 120000 index 0000000..2f5573a --- /dev/null +++ b/debug/init @@ -0,0 +1 @@ +../target/debug/init \ No newline at end of file diff --git a/debug/init-version b/debug/init-version new file mode 120000 index 0000000..2f5573a --- /dev/null +++ b/debug/init-version @@ -0,0 +1 @@ +../target/debug/init \ No newline at end of file diff --git a/etc/sshd_config b/etc/sshd_config new file mode 100644 index 0000000..bda3446 --- /dev/null +++ b/etc/sshd_config @@ -0,0 +1,7 @@ +KbdInteractiveAuthentication no +PasswordAuthentication no + +PubkeyAuthentication yes + +PermitRootLogin yes +AllowUsers root diff --git a/init-rs b/init-rs new file mode 120000 index 0000000..a0ecdcc --- /dev/null +++ b/init-rs @@ -0,0 +1 @@ +target/debug/init \ No newline at end of file diff --git a/modd.conf b/modd.conf index b0e96bb..0dbc428 100644 --- a/modd.conf +++ b/modd.conf @@ -1,14 +1,14 @@ modd.conf {} -go.??? **/*.go { - prep: go test ./... - prep: mkdir -p dist - prep: go build -o dist/init . - prep: go build -o dist/ ./tools/... +**/*.rs Cargo.* { + prep: cargo test + prep: cargo build + prep: debug/init-version } -dist/init Dockerfile { - prep: docker build -t novit-initrd-gen . - prep: docker run novit-initrd-gen |base64 -d >dist/initrd.new +target/debug/init Dockerfile { + prep: docker build --network host -t novit-initrd-gen . + prep: docker run --net=host --rm novit-initrd-gen |base64 -d >dist/initrd.new prep: mv dist/initrd.new dist/initrd + prep: ls -sh dist/initrd } diff --git a/run-docker b/run-docker new file mode 100755 index 0000000..fd93882 --- /dev/null +++ b/run-docker @@ -0,0 +1,2 @@ +docker build -t nv-initrd-test -f Dockerfile.test empty +docker run -d --name nv-initrd-test -it --privileged -v $PWD:/src --workdir /src/test-initrd nv-initrd-test diff --git a/run-test.sh b/run-test.sh index 4bc3626..6153735 100755 --- a/run-test.sh +++ b/run-test.sh @@ -10,7 +10,8 @@ if ! [ -e $disk2 ]; then qemu-img create -f qcow2 $disk2 10G fi -exec qemu-system-x86_64 -pidfile qemu.pid -kernel test-kernel -initrd test-initrd.cpio \ +exec qemu-system-x86_64 -pidfile qemu.pid \ + -kernel test-kernel -initrd test-initrd.cpio \ -smp 2 -m 2048 \ -netdev bridge,br=novit,id=eth0 -device virtio-net-pci,netdev=eth0 \ -drive file=$disk1,if=virtio \ diff --git a/src/blockdev.rs b/src/blockdev.rs new file mode 100644 index 0000000..2da8fac --- /dev/null +++ b/src/blockdev.rs @@ -0,0 +1,13 @@ +use tokio::fs; +use tokio::io::{AsyncReadExt, Result}; + +/// Checks if a block device is uninitialized. +/// It assumed that a initialized device always has a non-zero byte in the first 8kiB. +pub async fn is_uninitialized(dev_path: &str) -> Result { + let mut dev = fs::File::open(dev_path).await?; + + let mut buf = [0u8; 8 << 10]; + dev.read_exact(&mut buf).await?; + + Ok(buf.into_iter().all(|b| b == 0)) +} diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 0000000..ef68c36 --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs new file mode 100644 index 0000000..5abb076 --- /dev/null +++ b/src/bootstrap/config.rs @@ -0,0 +1,209 @@ +pub const TAKE_ALL: i16 = -1; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Config { + pub anti_phishing_code: String, + + pub keymap: Option, + pub modules: Option, + + pub auths: Vec, + + pub networks: Vec, + + #[serde(default)] + pub ssh: SSHServer, + + #[serde(default)] + pub pre_lvm_crypt: Vec, + #[serde(default)] + pub lvm: Vec, + #[serde(default)] + pub crypt: Vec, + + pub bootstrap: Bootstrap, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Auth { + pub name: String, + #[serde(rename = "sshKey")] + pub ssh_key: String, + pub password: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Network { + pub name: String, + pub interfaces: Vec, + pub script: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct NetworkInterface { + pub var: String, + pub n: i16, + pub regexps: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct SSHServer { + pub listen: String, + pub keys: SSHKeys, +} +impl Default for SSHServer { + fn default() -> Self { + Self { + listen: "[::]:22".to_string(), + keys: SSHKeys::default(), + } + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct SSHKeys { + dsa: Option, + rsa: Option, + ecdsa: Option, + ed25519: Option, +} +impl SSHKeys { + pub fn iter(&self) -> impl Iterator { + [ + self.dsa.iter(), + self.rsa.iter(), + self.ecdsa.iter(), + self.ed25519.iter(), + ] + .into_iter() + .flatten() + .map(String::as_str) + } +} +impl Default for SSHKeys { + fn default() -> Self { + Self { + dsa: Some("id_dsa".to_string()), + rsa: Some("id_rsa".to_string()), + ecdsa: Some("id_ecdsa".to_string()), + ed25519: Some("id_ed25519".to_string()), + } + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct LvmVG { + #[serde(alias = "vg")] + pub name: String, + pub pvs: LvmPV, + + #[serde(default)] + pub defaults: LvmLVDefaults, + + pub lvs: Vec, +} + +#[derive(Debug, Default, serde::Deserialize, serde::Serialize)] +pub struct LvmLVDefaults { + #[serde(default)] + pub fs: Filesystem, + #[serde(default)] + pub raid: Raid, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Filesystem { + Ext4, + Xfs, + Btrfs, + Other(String), +} + +impl Filesystem { + pub fn fstype(&self) -> &str { + use Filesystem as F; + match self { + F::Ext4 => "ext4", + F::Xfs => "xfs", + F::Btrfs => "btrfs", + F::Other(t) => t, + } + } +} + +impl Default for Filesystem { + fn default() -> Self { + Filesystem::Ext4 + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct LvmLV { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raid: Option, + #[serde(flatten)] + pub size: LvSize, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LvSize { + Size(String), + Extents(String), +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct LvmPV { + pub n: i16, + pub regexps: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct CryptDev { + pub name: String, + #[serde(flatten)] + pub filter: DevFilter, + pub optional: Option, +} +impl CryptDev { + pub fn optional(&self) -> bool { + self.optional.unwrap_or_else(|| self.filter.is_prefix()) + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DevFilter { + Dev(String), + Prefix(String), +} +impl DevFilter { + pub fn is_dev(&self) -> bool { + match self { + Self::Dev(_) => true, + _ => false, + } + } + pub fn is_prefix(&self) -> bool { + match self { + Self::Prefix(_) => true, + _ => false, + } + } +} + +#[derive(Debug, Default, Clone, serde::Deserialize, serde::Serialize)] +pub struct Raid { + pub mirrors: Option, + pub stripes: Option, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Bootstrap { + pub dev: String, + pub seed: Option, +} diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..fbfd7c9 --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,5 @@ +pub mod bootstrap; +pub mod connect_boot; +pub mod init; +pub mod init_input; +pub mod version; diff --git a/src/cmd/bootstrap.rs b/src/cmd/bootstrap.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/cmd/connect_boot.rs b/src/cmd/connect_boot.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/cmd/init.rs b/src/cmd/init.rs new file mode 100644 index 0000000..6236cc8 --- /dev/null +++ b/src/cmd/init.rs @@ -0,0 +1,269 @@ +use eyre::{format_err, Result}; +use log::{error, info, warn}; +use std::collections::BTreeSet as Set; +use std::os::unix::fs::symlink; +use tokio::sync::Mutex; +use tokio::{fs, process::Command}; + +use crate::{bootstrap::config::Config, cmd::version::version_string, input}; + +mod bootstrap; +mod dmcrypt; +mod lvm; +mod networks; +mod sshd; + +// devices used by any block target (DM, LVM...) +static USED_DEVS: Mutex> = Mutex::const_new(Set::new()); + +pub async fn run() { + if std::process::id() != 1 { + error!("init must run as PID 1, not {}", std::process::id()); + std::process::exit(1); + } + + unsafe { + use std::env; + env::set_var("PATH", "/bin:/sbin:/usr/bin:/usr/sbin"); + env::set_var("HOME", "/root"); + } + + info!("Welcome to {}", version_string()); + + let uname = nix::sys::utsname::uname().expect("uname should work"); + let kernel_version = uname.release().to_string_lossy(); + info!("Linux version {kernel_version}"); + + let cfg: Config = retry(async || { + let cfg = (fs::read("config.yaml").await) + .map_err(|e| format_err!("failed to read config: {e}"))?; + serde_yaml::from_slice(cfg.as_slice()) + .map_err(|e| format_err!("failed to parse config: {e}")) + }) + .await; + + info!("config loaded"); + info!("anti-phishing-code: {}", cfg.anti_phishing_code); + + // seem to occasionaly race with Child::wait + // tokio::spawn(child_reaper()); + + // open input channels + tokio::spawn(crate::input::answer_requests_from_stdin()); + tokio::spawn(crate::input::answer_requests_from_socket()); + + // mount basic filesystems + mount("none", "/proc", "proc", None).await; + mount("none", "/sys", "sysfs", None).await; + mount("none", "/dev", "devtmpfs", None).await; + mount("none", "/dev/pts", "devpts", Some("gid=5,mode=620")).await; + + // mount modules + if let Some(ref modules) = cfg.modules { + retry_or_ignore(async || { + info!("mounting modules"); + mount(modules, "/modules", "squashfs", None).await; + + fs::create_dir_all("/lib/modules").await?; + let modules_path = &format!("/modules/lib/modules/{kernel_version}"); + + if !std::fs::exists(modules_path)? { + return Err(format_err!( + "invalid modules package: {modules_path} should exist" + )); + } + + symlink(modules_path, format!("/lib/modules/{kernel_version}"))?; + Ok(()) + }) + .await; + } else { + warn!("modules NOT mounted (not configured)"); + } + + // init devices + info!("initializing devices"); + start_daemon("udevd", &[]).await; + + exec("udevadm", &["trigger", "-c", "add", "-t", "devices"]).await; + exec("udevadm", &["trigger", "-c", "add", "-t", "subsystems"]).await; + exec("udevadm", &["settle"]).await; + + // Wireguard VPN + // TODO startVPN() + + // SSH service + sshd::start(&cfg).await; + + // networks + networks::setup(&cfg).await; + + // pre-lvm dmcrypt devs + dmcrypt::setup(&cfg.pre_lvm_crypt).await; + + // LVM + lvm::setup(&cfg).await; + + // post-lvm dmcrypt devs + dmcrypt::setup(&cfg.crypt).await; + + // bootstrap the system + bootstrap::bootstrap(cfg).await; + + // finalize + retry(async || switch_root("/system")).await; +} + +fn switch_root(root: &str) -> Result<()> { + use std::ffi::CString; + + macro_rules! c { + ($s:expr) => { + CString::new($s)?.as_c_str() + }; + } + + info!("switching root"); + nix::unistd::execv( + c!("/sbin/switch_root"), + &[ + c!("switch_root"), + c!("-c"), + c!("/dev/console"), + c!(root), + c!("/sbin/init"), + ], + )?; + Ok(()) +} + +async fn mount(src: &str, dst: &str, fstype: &str, opts: Option<&str>) { + if let Err(e) = fs::create_dir_all(dst).await { + error!("failed to create dir {dst}: {e}"); + } + + match fstype { + "ext4" => { + exec("fsck", &["-p", src]).await; + } + _ => {} + } + + let mut args = vec![src, dst]; + if !fstype.is_empty() { + args.extend(&["-t", fstype]); + } + if let Some(opts) = opts { + args.extend(["-o", opts]); + } + exec("mount", &args).await; +} + +async fn start_daemon(prog: &str, args: &[&str]) { + let (cmd_str, mut cmd) = cmd_str(prog, args); + retry_or_ignore(async || { + info!("starting as daemon: {cmd_str}"); + cmd.spawn()?; + Ok(()) + }) + .await; +} + +async fn try_exec(prog: &str, args: &[&str]) -> Result<()> { + let (cmd_str, mut cmd) = cmd_str(prog, args); + info!("# {cmd_str}"); + + let s = cmd.status().await?; + if s.success() { + Ok(()) + } else { + Err(format_err!("command failed: {s}")) + } +} + +async fn exec(prog: &str, args: &[&str]) { + retry_or_ignore(async || try_exec(prog, args).await).await; +} + +async fn retry_or_ignore(mut action: impl AsyncFnMut() -> Result<()>) { + loop { + match action().await { + Ok(_) => return, + Err(e) => { + error!("{e}"); + + match input::read_choice(["[r]etry", "[i]gnore", "[s]hell"]).await { + 'r' => {} + 'i' => return, + 's' => exec_shell().await, + _ => unreachable!(), + } + } + } + } +} + +async fn retry(mut action: impl AsyncFnMut() -> Result) -> T { + loop { + match action().await { + Ok(v) => return v, + Err(e) => { + error!("{e}"); + + match input::read_choice(["[r]etry", "[s]hell"]).await { + 'r' => {} + 's' => exec_shell().await, + _ => unreachable!(), + } + } + } + } +} + +async fn exec_shell() { + let mut child = match Command::new("ash").spawn() { + Ok(c) => c, + Err(e) => { + error!("failed to exec shell: {e}"); + return; + } + }; + + let _ = child.wait().await; +} + +fn cmd_str(prog: &str, args: &[&str]) -> (String, Command) { + use std::borrow::Cow; + + let mut buf = String::new(); + + buf.push_str(&shell_escape::escape(Cow::Borrowed(prog))); + + for &arg in args { + buf.push(' '); + buf.push_str(&shell_escape::escape(Cow::Borrowed(arg))); + } + + let mut cmd = Command::new(prog); + cmd.args(args); + + (buf, cmd) +} + +#[allow(unused)] +async fn child_reaper() { + use nix::sys::wait::{waitpid, WaitPidFlag}; + use nix::unistd::Pid; + use tokio::signal::unix::{signal, SignalKind}; + + let Ok(mut sigs) = + signal(SignalKind::child()).inspect_err(|e| warn!("failed to setup SIGCHLD handler: {e}")) + else { + return; + }; + + loop { + sigs.recv().await; + let _ = waitpid(Some(Pid::from_raw(-1)), Some(WaitPidFlag::WNOHANG)); + } +} diff --git a/src/cmd/init/bootstrap.rs b/src/cmd/init/bootstrap.rs new file mode 100644 index 0000000..6180d61 --- /dev/null +++ b/src/cmd/init/bootstrap.rs @@ -0,0 +1,310 @@ +use eyre::{format_err, Result}; +use log::{info, warn}; +use std::path::Path; +use tokio::{ + fs, + io::{AsyncBufReadExt, BufReader}, +}; + +use super::{exec, mount, retry, retry_or_ignore, try_exec}; +use crate::bootstrap::config::Config; +use crate::dkl; + +pub async fn bootstrap(cfg: Config) { + let bs = cfg.bootstrap; + + retry_or_ignore(async || { + fs::create_dir_all("/boostrap").await?; + mount(&bs.dev, "/bootstrap", "auto", None).await; + Ok(()) + }) + .await; + + let boot_version = "current"; // FIXME need to parse kernel cmdline + 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).await?; + Ok(serde_yaml::from_slice(&sys_cfg_bytes)?) + }) + .await; + + mount_system(&sys_cfg, base_dir).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; +} + +async fn seed_config(base_dir: &str, seed_url: &Option) -> Result> { + 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")); + } + + Ok(fs::read(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(()) +} + +async fn mount_system(cfg: &dkl::Config, bs_dir: &str) { + let mem_dir = "/mem"; + mount("none", mem_dir, "tmpfs", Some("size=512m")).await; + + let layers_dir = &format!("{mem_dir}/layers"); + let mut lower_dir = String::new(); + + for layer in &cfg.layers { + let src = if layer == "modules" { + "/modules.sqfs" + } else { + &format!("{bs_dir}/{layer}.fs") + }; + let tgt = &format!("{mem_dir}/{layer}.fs"); + retry(async || { + info!("copying layer {layer} from {src}"); + fs::copy(src, tgt).await?; + Ok(()) + }) + .await; + + let layer_dir = &format!("{layers_dir}/{layer}"); + mount(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; + + mount( + "none", + "/system", + "overlay", + Some(&format!( + "lowerdir={lower_dir},upperdir={upper_dir},workdir={work_dir}" + )), + ) + .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; +} + +fn chroot(root: &str, path: &str) -> String { + format!("{root}/{}", path.trim_start_matches(|c| c == '/')) +} + +async fn apply_files(files: &[dkl::File], root: &str) -> Result<()> { + for file in files { + let path = chroot(root, &file.path); + let path = Path::new(&path); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + use crate::dkl::FileKind as K; + match &file.kind { + K::Content(content) => fs::write(path, content.as_bytes()).await?, + K::Dir(true) => fs::create_dir(path).await?, + K::Dir(false) => {} // shouldn't happen, but semantic is to ignore + K::Symlink(tgt) => fs::symlink(tgt, path).await?, + } + + match file.kind { + K::Symlink(_) => warn!("{}: can set perms on symlink", file.path), + _ => set_perms(path, file.mode).await?, + } + + info!("created {}", file.path); + } + + Ok(()) +} + +async fn set_perms(path: impl AsRef, mode: Option) -> std::io::Result<()> { + if let Some(mode) = mode.filter(|m| *m != 0) { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::Permissions::from_mode(mode); + fs::set_permissions(path, mode).await?; + } + Ok(()) +} + +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( + &m.dev, + &path, + m.r#type.as_ref().map_or("", |v| v.as_str()), + m.options + .as_ref() + .filter(|v| !v.is_empty()) + .map(|s| s.as_str()), + ) + .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(()) +} diff --git a/src/cmd/init/dmcrypt.rs b/src/cmd/init/dmcrypt.rs new file mode 100644 index 0000000..77c4439 --- /dev/null +++ b/src/cmd/init/dmcrypt.rs @@ -0,0 +1,142 @@ +use eyre::{format_err, Result}; +use log::{error, info}; +use std::collections::BTreeSet as Set; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::sync::Mutex; + +use super::{retry_or_ignore, USED_DEVS}; +use crate::blockdev::is_uninitialized; +use crate::bootstrap::config::{CryptDev, DevFilter}; +use crate::fs::walk_dir; +use crate::input; + +pub async fn setup(devs: &[CryptDev]) { + if devs.is_empty() { + return; + } + + let mut used_devs = USED_DEVS.lock().await; + + // CryptDev.name that have a least one assignment done + let mut done = Set::new(); + + // dmcrypt devices opened here + let mut done_crypt = Set::new(); + + retry_or_ignore(async || { + let all_devs = walk_dir("/dev").await; + + for dev in devs { + let mut mappings = find_dev(dev, &all_devs); + mappings.retain(|(_, dev_path)| !used_devs.contains(dev_path)); + + if mappings.is_empty() && !dev.optional() && !done.contains(&dev.name) { + return Err(format_err!("no device found for crypt dev {}", dev.name)); + } + + for (crypt_dev, dev_path) in mappings { + if done_crypt.contains(&crypt_dev) { + continue; + } + + info!("crypt dev {crypt_dev}: using {dev_path}"); + + crypt_open(&crypt_dev, &dev_path).await?; + + done_crypt.insert(crypt_dev); + used_devs.insert(dev_path); + done.insert(&dev.name); + } + } + + Ok(()) + }) + .await; +} + +static PREV_PW: Mutex = Mutex::const_new(String::new()); + +async fn crypt_open(crypt_dev: &str, dev_path: &str) -> Result<()> { + 'open_loop: loop { + let mut prev_pw = PREV_PW.lock().await; + let prompt = if prev_pw.is_empty() { + format!("crypt password for {crypt_dev}? ") + } else { + format!("crypt password for {crypt_dev} (enter = reuse previous)? ") + }; + + let mut pw = input::read_password(prompt).await; + if pw.is_empty() { + pw = prev_pw.clone(); + } + if pw.is_empty() { + error!("empty password provided!"); + continue; + } + + *prev_pw = pw.clone(); + + if cryptsetup(&pw, ["open", dev_path, crypt_dev]).await? { + return Ok(()); + } + + error!("crypt open {crypt_dev} from {dev_path} failed"); + + if is_uninitialized(dev_path).await? { + // we can format the device + match input::read_choice(["[f]ormat", "[r]etry", "[i]gnore"]).await { + 'r' => continue 'open_loop, + 'i' => return Ok(()), + 'f' => { + if !cryptsetup(&pw, ["luksFormat", dev_path]).await? { + return Err(format_err!("cryptsetup luksFormat failed")); + } + if !cryptsetup(&pw, ["open", dev_path, crypt_dev]).await? { + return Err(format_err!("open after format failed")); + } + return Ok(()); + } + _ => unreachable!(), + } + } else { + // device looks initialized, don't allow format + match input::read_choice(["[r]etry", "[i]gnore"]).await { + 'r' => continue 'open_loop, + 'i' => return Ok(()), + _ => unreachable!(), + } + } + } +} + +async fn cryptsetup(pw: &str, args: [&str; N]) -> Result { + let mut child = Command::new("cryptsetup") + .args(args) + .arg("--key-file=-") + .stdin(Stdio::piped()) + .spawn()?; + + (child.stdin.as_mut().unwrap()) + .write_all(pw.as_bytes()) + .await?; + + Ok(child.wait().await?.success()) +} + +fn find_dev(dev: &CryptDev, all_devs: &[String]) -> Vec<(String, String)> { + let dev_name = &dev.name; + match dev.filter { + DevFilter::Dev(ref path) => (all_devs.iter()) + .filter(|dev_path| dev_path == &path) + .map(|dev_path| (dev.name.clone(), dev_path.clone())) + .collect(), + DevFilter::Prefix(ref prefix) => (all_devs.iter()) + .filter_map(|path| { + let suffix = path.strip_prefix(prefix)?; + Some((format!("{dev_name}{suffix}"), path.clone())) + }) + .collect(), + } +} diff --git a/src/cmd/init/lvm.rs b/src/cmd/init/lvm.rs new file mode 100644 index 0000000..6027c05 --- /dev/null +++ b/src/cmd/init/lvm.rs @@ -0,0 +1,208 @@ +use eyre::{format_err, Result}; +use log::{error, info}; +use tokio::process::Command; + +use super::{exec, retry, retry_or_ignore, USED_DEVS}; +use crate::bootstrap::config::{Config, Filesystem, LvSize, LvmLV, LvmVG, TAKE_ALL}; +use crate::fs::walk_dir; +use crate::{blockdev, lvm}; + +pub async fn setup(cfg: &Config) { + if cfg.lvm.is_empty() { + info!("no LVM VG configured"); + return; + } + + exec("pvscan", &[]).await; + exec("vgscan", &["--mknodes"]).await; + + for vg in &cfg.lvm { + retry_or_ignore(async || setup_vg(vg).await).await + } + + let lvs = retry(lvm::lvs).await; + + for vg in &cfg.lvm { + let vg_name = vg.name.as_str(); + + for lv in &vg.lvs { + let lv_name = lv.name.as_str(); + + if (lvs.iter()).any(|lv| lv.equal_name(vg_name, lv_name)) { + info!("LVM LV {vg_name}/{lv_name} exists"); + } else { + retry_or_ignore(async || setup_lv(&vg, &lv).await).await; + } + } + } + + exec("vgchange", &["--sysinit", "-a", "ly"]).await; + + for vg in &cfg.lvm { + for lv in &vg.lvs { + retry_or_ignore(async || format_lv(&vg, &lv).await).await; + } + } +} + +async fn setup_vg(vg: &LvmVG) -> Result<()> { + let vg_name = vg.name.as_str(); + + let pvs = retry(lvm::pvs).await; + + let mut dev_done = pvs.iter().filter(|pv| pv.vg_name == vg.name).count(); + let dev_needed = vg.pvs.n; + macro_rules! missing_count { + () => { + (dev_needed as usize) - dev_done + }; + } + + if dev_needed == TAKE_ALL { + if dev_done == 0 { + info!("setting up LVM VG {vg_name} using all matching devices"); + } else { + // in "take all" mode, don't extend as existing vg at boot + info!("LVM VG {vg_name} exists"); + return Ok(()); + } + } else if dev_done >= (dev_needed as usize) { + info!("LVM VG {vg_name} exists with enough devices"); + return Ok(()); // already set up + } else { + info!("setting up LVM VG {vg_name} ({dev_done}/{dev_needed} devices configured)"); + } + + let regexps: Vec = (vg.pvs.regexps.iter()) + .filter_map(|re_str| { + (re_str.parse()) + .inspect_err(|e| error!("invalid regex ignored: {re_str:?}: {e}")) + .ok() + }) + .collect(); + + let mut used_devs = USED_DEVS.lock().await; + + let matching_devs = (walk_dir("/dev").await.into_iter()) + .filter(|path| !used_devs.contains(path.as_str())) + .filter(|path| regexps.iter().any(|re| re.is_match(path))); + + let devs: Vec<_> = if dev_needed == TAKE_ALL { + matching_devs.collect() + } else { + matching_devs.take(missing_count!()).collect() + }; + + let cmd = if dev_done == 0 { + "vgcreate" + } else { + "vgextend" + }; + let status = (Command::new(cmd).arg(vg_name).args(&devs)) + .status() + .await?; + if !status.success() { + return Err(format_err!("{cmd} failed: {status}")); + } + + dev_done += devs.len(); + used_devs.extend(devs); + + if dev_needed != TAKE_ALL && dev_done < (dev_needed as usize) { + return Err(format_err!( + "LVM VG {vg_name} needs {} more device(s)", + missing_count!() + )); + } + + Ok(()) +} + +async fn setup_lv(vg: &LvmVG, lv: &LvmLV) -> Result<()> { + let name = format!("{}/{}", vg.name, lv.name); + info!("creating LV {name}"); + + let mut cmd = Command::new("lvcreate"); + cmd.arg(&vg.name); + cmd.args(&["--name", &lv.name]); + + match &lv.size { + LvSize::Size(sz) => cmd.args(&["-L", sz]), + LvSize::Extents(sz) => cmd.args(&["-l", sz]), + }; + + let raid = lv.raid.as_ref().unwrap_or(&vg.defaults.raid); + + if let Some(mirrors) = raid.mirrors { + cmd.args(&["--mirrors", &mirrors.to_string()]); + } + if let Some(stripes) = raid.stripes { + cmd.args(&["--stripes", &stripes.to_string()]); + } + + let status = cmd.status().await?; + if !status.success() { + return Err(format_err!("lvcreate failed: {status}")); + } + + Ok(()) +} + +async fn format_lv(vg: &LvmVG, lv: &LvmLV) -> Result<()> { + let name = &format!("{}/{}", vg.name, lv.name); + let dev = &format!("/dev/{name}"); + + if !blockdev::is_uninitialized(&dev).await? { + info!("{dev} looks initialized"); + return Ok(()); + } + + let fs = lv.fs.as_ref().unwrap_or(&vg.defaults.fs); + info!("initializing {} filesystem on {dev}", fs.fstype()); + + let mkfs = format!("mkfs.{}", fs.fstype()); + + let mut cmd = Command::new(&mkfs); + + // filesystem specific flags + match fs { + Filesystem::Ext4 => { + cmd.arg("-F"); + } + Filesystem::Btrfs | Filesystem::Xfs => { + cmd.arg("-f"); + } + &Filesystem::Other(_) => {} + } + + cmd.arg(dev); + + let mut child = match cmd.spawn() { + Ok(v) => v, + Err(e) => { + // try simple fixes + match fs { + Filesystem::Xfs => install_package("xfsprogs").await?, + Filesystem::Btrfs => install_package("btrs-progs").await?, + _ => Err(format_err!("{mkfs} failed: {e}"))?, + } + cmd.spawn().map_err(|e| format_err!("{mkfs} failed: {e}"))? + } + }; + + let status = child.wait().await?; + if !status.success() { + return Err(format_err!("{mkfs} failed: {status}")); + } + + Ok(()) +} + +async fn install_package(pkg: &str) -> Result<()> { + let status = Command::new("apk").arg("add").arg(pkg).status().await?; + if status.success() { + Ok(()) + } else { + Err(format_err!("failed to install package {pkg}: {status}")) + } +} diff --git a/src/cmd/init/networks.rs b/src/cmd/init/networks.rs new file mode 100644 index 0000000..1ecaeef --- /dev/null +++ b/src/cmd/init/networks.rs @@ -0,0 +1,94 @@ +use itertools::Itertools; +use log::{info, warn}; +use std::collections::BTreeSet as Set; +use tokio::process::Command; + +use super::{format_err, retry_or_ignore, Config, Result}; +use crate::{ + bootstrap::config, + udev, + utils::{select_n_by_regex, NameAliases}, +}; + +pub async fn setup(cfg: &Config) { + if cfg.networks.is_empty() { + warn!("no networks configured"); + return; + } + + let mut assigned = Set::new(); + + for net in &cfg.networks { + retry_or_ignore(async || setup_network(net, &mut assigned).await).await; + } +} + +async fn setup_network(net: &config::Network, assigned: &mut Set) -> Result<()> { + info!("setting up network {}", net.name); + + let netdevs = get_interfaces()? + .filter(|dev| !assigned.contains(dev.name())) + .collect::>(); + + for dev in &netdevs { + info!( + "- available network device: {}, aliases [{}]", + dev.name(), + dev.aliases().join(", ") + ); + } + + let mut cmd = Command::new("ash"); + cmd.arg("-c"); + cmd.arg(&net.script); + + let mut selected = Vec::new(); + + for iface in &net.interfaces { + let var = &iface.var; + + let netdevs = netdevs.iter().filter(|na| !assigned.contains(na.name())); + let if_names = select_n_by_regex(iface.n, &iface.regexps, netdevs); + + if if_names.is_empty() { + return Err(format_err!("- no interface match for {var:?}")); + } + + let value = if_names.join(" "); + info!("- {var}={value}"); + cmd.env(var, value); + + selected.extend(if_names); + } + + info!("- running script"); + let status = cmd.status().await?; + if !status.success() { + return Err(format_err!("setup script failed: {status}")); + } + + assigned.extend(selected); + Ok(()) +} + +fn get_interfaces() -> Result> { + Ok(udev::get_devices("net")?.into_iter().map(|dev| { + let mut na = NameAliases::new(dev.sysname().to_string()); + + for (property, value) in dev.properties() { + if [ + "INTERFACE", + "ID_NET_NAME", + "ID_NET_NAME_PATH", + "ID_NET_NAME_MAC", + "ID_NET_NAME_SLOT", + ] + .contains(&property) + { + na.push(value.to_string()); + } + } + + na + })) +} diff --git a/src/cmd/init/sshd.rs b/src/cmd/init/sshd.rs new file mode 100644 index 0000000..5728003 --- /dev/null +++ b/src/cmd/init/sshd.rs @@ -0,0 +1,91 @@ +use log::{info, warn}; +use std::fs; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::process::Stdio; +use tokio::net; +use tokio::process::Command; + +use super::{retry_or_ignore, Config}; + +pub async fn start(cfg: &Config) { + retry_or_ignore(async || { + info!("ssh: writing authorized keys"); + + let ssh_dir = "/root/.ssh"; + let authorized_keys = format!("{ssh_dir}/authorized_keys"); + + fs::create_dir_all(ssh_dir)?; + fs::set_permissions(ssh_dir, fs::Permissions::from_mode(0o700))?; + + let mut ak = Vec::new(); + + for auth in &cfg.auths { + writeln!(ak, "{} {}", auth.ssh_key, auth.name)?; + } + + fs::write(authorized_keys, ak)?; + Ok(()) + }) + .await; + + retry_or_ignore(async || { + let mut sshd_args = Vec::new(); + + sshd_args.extend(["-i", "-E", "/var/log/sshd.log"]); + + for key_path in cfg.ssh.keys.iter() { + if !fs::exists(key_path).is_ok_and(|b| b) { + info!("ssh: host key not found (ignored): {key_path}"); + continue; + } + sshd_args.extend(["-h", key_path]); + } + + let sshd_args = sshd_args.into_iter().map(String::from).collect(); + + // don't pre-start sshd as it should rarely be useful at this stage, use inetd-style. + let listen_addr = cfg.ssh.listen.clone(); + info!("ssh: starting listener on {listen_addr}"); + + let listener = net::TcpListener::bind(listen_addr).await?; + + tokio::spawn(handle_ssh_connections(listener, sshd_args)); + + Ok(()) + }) + .await; +} + +async fn handle_ssh_connections(listener: net::TcpListener, sshd_args: Vec) { + loop { + let (stream, remote) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + warn!("ssh: listener stopped: {e}"); + return; + } + }; + + info!("ssh: new connection from {remote}"); + + use std::os::unix::io::{AsRawFd, FromRawFd}; + let fd = stream.as_raw_fd(); + + let mut cmd = Command::new("/usr/sbin/sshd"); + cmd.args(&sshd_args); + + cmd.stdin(unsafe { Stdio::from_raw_fd(fd) }); + cmd.stdout(unsafe { Stdio::from_raw_fd(fd) }); + cmd.stderr(Stdio::null()); + + match cmd.spawn() { + Ok(mut child) => { + tokio::spawn(async move { child.wait().await }); + } + Err(e) => { + warn!("ssh: failed to start server: {e}"); + } + } + } +} diff --git a/src/cmd/init_input.rs b/src/cmd/init_input.rs new file mode 100644 index 0000000..c5aa78b --- /dev/null +++ b/src/cmd/init_input.rs @@ -0,0 +1,11 @@ +use crate::input; + +pub async fn run() { + tokio::spawn(async { + if let Err(e) = input::forward_requests_from_socket().await { + eprintln!("failed to forwards requests from socket: {e}"); + std::process::exit(1); + } + }); + input::answer_requests_from_stdin().await; +} diff --git a/src/cmd/version.rs b/src/cmd/version.rs new file mode 100644 index 0000000..9ec38d8 --- /dev/null +++ b/src/cmd/version.rs @@ -0,0 +1,12 @@ +pub fn run() { + println!("{}", version_string()); +} + +pub fn version_string() -> String { + format!( + "Direktil {} v{} (git commit {})", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + env!("GIT_COMMIT") + ) +} diff --git a/src/dkl.rs b/src/dkl.rs new file mode 100644 index 0000000..13b8fec --- /dev/null +++ b/src/dkl.rs @@ -0,0 +1,57 @@ +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Config { + pub layers: Vec, + pub root_user: RootUser, + pub mounts: Vec, + pub files: Vec, + pub groups: Vec, + pub users: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct RootUser { + #[serde(skip_serializing_if = "Option::is_none")] + pub password_hash: Option, + pub authorized_keys: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Mount { + pub r#type: Option, + pub dev: String, + pub path: String, + pub options: Option, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Group { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub gid: Option, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct User { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gid: Option, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct File { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(flatten)] + pub kind: FileKind, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileKind { + Content(String), + Symlink(String), + Dir(bool), +} diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..a1fcf75 --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,43 @@ +use log::warn; +use std::io::Result; +use tokio::fs; + +pub async fn walk_dir(dir: &str) -> Vec { + let mut todo = Vec::new(); + if let Ok(rd) = read_dir(dir).await { + todo.push(rd); + } + + let mut results = Vec::new(); + + while let Some(rd) = todo.pop() { + for (path, is_dir) in rd { + if is_dir { + let Ok(child_rd) = (read_dir(&path).await) + .inspect_err(|e| warn!("reading dir {path} failed: {e}")) + else { + continue; + }; + todo.push(child_rd); + } else { + results.push(path); + } + } + } + + results +} + +async fn read_dir(dir: &str) -> Result> { + let mut rd = fs::read_dir(dir).await?; + let mut entries = Vec::new(); + + while let Some(entry) = rd.next_entry().await? { + if let Some(path) = entry.path().to_str() { + entries.push((path.to_string(), entry.file_type().await?.is_dir())); + } + } + + entries.sort(); // we want a deterministic & intuitive order + Ok(entries) +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..56d6839 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,277 @@ +use log::warn; +use std::fmt::Display; +use std::sync::{Arc, LazyLock}; +use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net; +use tokio::sync::{oneshot, watch, Mutex}; + +pub async fn read_line(prompt: impl Display) -> String { + read(prompt, false).await +} + +pub async fn read_password(prompt: impl Display) -> String { + read(prompt, true).await +} + +fn choice_char(s: &str) -> char { + s.chars().skip_while(|c| *c != '[').skip(1).next().unwrap() +} + +#[test] +fn test_choice_char() { + assert_eq!('r', choice_char("[r]etry")); + assert_eq!('b', choice_char("re[b]boot")); +} + +/// ```no_run +/// use init::input; +/// +/// #[tokio::main(flavor = "current_thread")] +/// async fn main() { +/// tokio::spawn(input::answer_requests_from_stdin()); +/// match input::read_choice(["[r]etry","[i]gnore","re[b]oot"]).await { +/// 'r' => todo!(), +/// 'i' => todo!(), +/// 'b' => todo!(), +/// _ => unreachable!(), +/// } +/// } +/// ``` +pub async fn read_choice(choices: [&str; N]) -> char { + let chars = choices.map(choice_char); + + let mut prompt = String::new(); + for s in choices { + if !prompt.is_empty() { + prompt.push_str(", "); + } + prompt.push_str(s); + } + prompt.push_str("? "); + + loop { + let line = read_line(&prompt).await; + let Some(ch) = line.chars().nth(0) else { + continue; + }; + + for choice in chars { + if ch == choice { + return choice; + } + } + } +} + +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub struct InputRequest { + prompt: String, + hide: bool, +} + +pub type Reply = Arc>>>; + +static REQ: LazyLock>>> = LazyLock::new(|| { + let (tx, _) = watch::channel(None); + Mutex::new(tx) +}); +static READ_MUTEX: Mutex<()> = Mutex::const_new(()); + +async fn read(prompt: impl Display, hide_input: bool) -> String { + let _read_lock = READ_MUTEX.lock(); + + let req = InputRequest { + prompt: prompt.to_string(), + hide: hide_input, + }; + + let (tx, rx) = oneshot::channel(); + let reply = Arc::new(Mutex::new(Some(tx))); + + REQ.lock().await.send_replace(Some((req, reply))); + + let input = rx.await.expect("reply sender should not be closed"); + + REQ.lock().await.send_replace(None); + + input +} + +pub async fn answer_requests_from_stdin() { + let mut stdin = BufReader::new(io::stdin()).lines(); + let mut stdout = io::stdout(); + + let mut current_req = REQ.lock().await.subscribe(); + current_req.mark_changed(); + + loop { + // TODO check is stdin has been closed (using C-c is enough for now) + (current_req.changed().await).expect("input request should not close"); + + let Some((req, reply)) = current_req.borrow_and_update().clone() else { + continue; + }; + + // handle hide + let mut saved_termios = None; + if req.hide { + match termios::Termios::from_fd(0) { + Ok(mut tio) => { + saved_termios = Some(tio.clone()); + tio.c_lflag &= !termios::ECHO; + if let Err(e) = termios::tcsetattr(0, termios::TCSAFLUSH, &tio) { + warn!("password may be echoed! {e}"); + } + } + Err(e) => { + warn!("password may be echoed! {e}"); + } + } + } + + // print the prompt and wait for user input + stdout.write_all(req.prompt.as_bytes()).await.unwrap(); + stdout.flush().await.unwrap(); + + tokio::select!( + r = stdin.next_line() => { + let Ok(Some(line)) = r else { + warn!("stdin closed"); + return; + }; + + if let Some(tx) = reply.lock().await.take() { + let _ = tx.send(line); + } + + if saved_termios.is_some() { + // final '\n' is hidden too so fix it + stdout.write_all(b"\n").await.unwrap(); + stdout.flush().await.unwrap(); + } + } + _ = current_req.changed() => { + // reply came from somewhere else + stdout.write_all(b"\n").await.unwrap(); + stdout.flush().await.unwrap(); + + current_req.mark_changed(); + } + ); + + // restore term if input was hidden + if let Some(tio) = saved_termios { + if let Err(e) = termios::tcsetattr(0, termios::TCSAFLUSH, &tio) { + warn!("failed to restore pty attrs: {e}"); + } + } + } +} + +const SOCKET_PATH: &str = "/run/init.sock"; + +pub async fn answer_requests_from_socket() { + let Ok(listener) = net::UnixListener::bind(SOCKET_PATH) + .inspect_err(|e| warn!("failed start input socket listener: {e}")) + else { + return; + }; + + loop { + let Ok((conn, _)) = (listener.accept()) + .await + .inspect_err(|e| warn!("input socket listener failed: {e}")) + else { + return; + }; + + tokio::spawn(handle_connection(conn)); + } +} + +async fn handle_connection(conn: net::UnixStream) { + let mut current_req = REQ.lock().await.subscribe(); + current_req.mark_changed(); + + let (rd, mut wr) = io::split(conn); + let mut rd = BufReader::new(rd).lines(); + + loop { + (current_req.changed().await).expect("input request should not close"); + let Some((req, reply)) = current_req.borrow_and_update().clone() else { + if wr.write_all(b"null\n").await.is_err() { + return; + } + if wr.flush().await.is_err() { + return; + } + + continue; + }; + + { + let mut buf = serde_json::to_vec(&req).unwrap(); + buf.push(b'\n'); + + if (wr.write_all(&buf).await).is_err() { + return; + } + if wr.flush().await.is_err() { + return; + } + } + + tokio::select!( + r = rd.next_line() => { + let Ok(Some(line)) = r else { + return; // closed + }; + + if let Some(tx) = reply.lock().await.take() { + let _ = tx.send(line); + } + } + _ = current_req.changed() => { + // reply came from somewhere else + current_req.mark_changed(); + } + ); + } +} + +pub async fn forward_requests_from_socket() -> eyre::Result<()> { + let stream = net::UnixStream::connect(SOCKET_PATH).await?; + + let (rd, mut wr) = io::split(stream); + let mut rd = BufReader::new(rd).lines(); + + let mut line = rd.next_line().await?; + + loop { + let Some(req) = line else { + return Ok(()); + }; + + let req: Option = serde_json::from_str(&req)?; + + let Some(req) = req else { + // request answered from somewhere else + REQ.lock().await.send_replace(None); + line = rd.next_line().await?; + continue; + }; + + tokio::select!( + mut r = read(req.prompt, req.hide) => { + r.push('\n'); + wr.write_all(r.as_bytes()).await?; + wr.flush().await?; + + line = rd.next_line().await?; + } + l = rd.next_line() => { + line = l?; + } + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..228056a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod bootstrap; +pub mod cmd; +pub mod lsblk; +pub mod lvm; +pub mod udev; +pub mod utils; +pub mod input; +pub mod blockdev; +pub mod fs; +pub mod dkl; +pub mod dklog; diff --git a/src/lsblk.rs b/src/lsblk.rs new file mode 100644 index 0000000..15049e2 --- /dev/null +++ b/src/lsblk.rs @@ -0,0 +1,27 @@ +use std::io; +use std::process::Command; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct Report { + pub blockdevices: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct BlockDev { + pub name: String, + #[serde(rename = "maj:min")] + pub maj_min: String, + pub rm: bool, + pub size: String, + pub ro: bool, + #[serde(rename = "type")] + pub dev_type: String, + pub mountpoints: Vec>, + #[serde(default)] + pub children: Vec, +} + +pub fn report() -> io::Result { + let output = Command::new("lsblk").arg("--json").output()?; + Ok(serde_json::from_slice(output.stdout.as_slice()).unwrap()) +} diff --git a/src/lvm.rs b/src/lvm.rs new file mode 100644 index 0000000..784326b --- /dev/null +++ b/src/lvm.rs @@ -0,0 +1,102 @@ +use eyre::{format_err, Result}; +use tokio::process::Command; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct Report { + report: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +enum ReportObj { + #[serde(rename = "pv")] + PV(Vec), + #[serde(rename = "vg")] + VG(Vec), + #[serde(rename = "lv")] + LV(Vec), +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct PV { + pub pv_name: String, + pub vg_name: String, + pub pv_fmt: String, + pub pv_attr: String, + pub pv_size: String, + pub pv_free: String, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct VG { + pub vg_name: String, + pub pv_count: String, + pub lv_count: String, + pub snap_count: String, + pub vg_attr: String, + pub vg_size: String, + pub vg_free: String, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct LV { + pub lv_name: String, + pub vg_name: String, + pub lv_attr: String, + pub lv_size: String, + pub pool_lv: String, + pub origin: String, + pub data_percent: String, + pub metadata_percent: String, + pub move_pv: String, + pub mirror_log: String, + pub copy_percent: String, + pub convert_lv: String, +} + +impl LV { + pub fn equal_name(&self, vg_name: &str, lv_name: &str) -> bool { + vg_name == &self.vg_name && lv_name == &self.lv_name + } +} + +pub async fn pvs() -> Result> { + report_cmd("pvs", |o| match o { + ReportObj::PV(pv) => Some(pv), + _ => None, + }) + .await +} + +pub async fn vgs() -> Result> { + report_cmd("vgs", |o| match o { + ReportObj::VG(vg) => Some(vg), + _ => None, + }) + .await +} + +pub async fn lvs() -> Result> { + report_cmd("lvs", |o| match o { + ReportObj::LV(lv) => Some(lv), + _ => None, + }) + .await +} + +async fn report_cmd(cmd: &str, find: fn(ReportObj) -> Option>) -> Result> { + let output = Command::new(cmd) + .arg("--reportformat=json") + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr.trim_ascii()); + return Err(format_err!("{cmd} failed: {}", stderr)); + } + + let report: Report = serde_json::from_slice(&output.stdout).unwrap(); + Ok((report.report.into_iter()) + .filter_map(find) + .flatten() + .collect()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..98352c4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,40 @@ +use eyre::Result; +use log::{error, warn}; +use std::env; +use std::process::exit; + +use init::cmd; +use init::dklog; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + tokio::spawn(dklog::LOG.copy_to(tokio::io::stderr())); + dklog::init(); + + let call_name = env::args().next().unwrap_or("init".into()); + let call_name = (call_name.rsplit_once('/').map(|(_, n)| n)).unwrap_or(call_name.as_str()); + + if call_name == "init" { + tokio::spawn(async { + let Ok(log_file) = (tokio::fs::File::create("/var/log/init.log").await) + .inspect_err(|e| warn!("failed to open init.log: {e}")) + else { + return; + }; + dklog::LOG.copy_to(log_file).await; + }); + } + + match call_name { + "init" => cmd::init::run().await, + "init-version" => cmd::version::run(), + "init-connect" => cmd::init_input::run().await, + + _ => { + error!("invalid call name: {call_name:?}"); + exit(1); + } + }; + + Ok(()) +} diff --git a/src/udev.rs b/src/udev.rs new file mode 100644 index 0000000..67c5f72 --- /dev/null +++ b/src/udev.rs @@ -0,0 +1,65 @@ +use eyre::Result; +use log::error; + +pub struct Device { + sysname: String, + output: String, +} + +impl Device { + pub fn sysname(&self) -> &str { + self.sysname.as_str() + } + + pub fn properties(&self) -> impl Iterator { + self.output + .lines() + .filter_map(|line| line.strip_prefix("E: ")?.split_once('=')) + } +} + +pub fn get_devices(class: &str) -> Result> { + let mut devices = Vec::new(); + + // none of libudev and udev crates were able to list network devices. + // falling back to manual sysfs scanning :( + // + // Even when given a syspath, + // - udev crate failed to see all properties; + // - libudev crate segfaulted on the second property (SYSNUM ok, then segfault). + // falling back to parsing udevadm output :( + // + // The best fix would be to check what's wrong with udev crate. + + let entries = std::fs::read_dir(format!("/sys/class/{class}"))?; + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + + let path = entry.path(); + let path = path.to_string_lossy(); + + let output = std::process::Command::new("udevadm") + .args(&["info", &format!("--path={path}")]) + .stderr(std::process::Stdio::piped()) + .output()?; + + if !output.status.success() { + error!("udevadm fail for {path}"); + continue; + } + + let output = String::from_utf8_lossy(&output.stdout); + + let name = entry.file_name(); + let dev = Device { + sysname: name.to_string_lossy().to_string(), + output: output.into_owned(), + }; + + devices.push(dev); + } + + Ok(devices) +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..7bcd181 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,61 @@ +use log::error; +use std::collections::BTreeSet as Set; + +pub struct NameAliases { + name: String, + aliases: Set, +} + +impl NameAliases { + pub fn new(name: String) -> Self { + Self { + name, + aliases: Set::new(), + } + } + + pub fn name(&self) -> &str { + self.name.as_str() + } + pub fn aliases(&self) -> impl Iterator { + self.aliases.iter().map(|s| s.as_str()) + } + + pub fn iter(&self) -> impl Iterator { + std::iter::once(self.name()).chain(self.aliases()) + } + + pub fn push(&mut self, alias: String) { + if self.name == alias { + return; + } + self.aliases.insert(alias); + } +} + +pub fn select_n_by_regex<'t>( + n: i16, + regexs: &Vec, + nas: impl Iterator, +) -> Vec { + // compile regexs + let regexs: Vec<_> = (regexs.iter()) + .filter_map(|re| { + regex::Regex::new(re) + .inspect_err(|e| error!("invalid regex ignored: {re:?}: {e}")) + .ok() + }) + .collect(); + + let matching = |name| regexs.iter().any(|re| re.is_match(name)); + + let nas = nas + .filter(|na| na.iter().any(matching)) + .map(|na| na.name().to_string()); + + if n == -1 { + nas.collect() + } else { + nas.take(n as usize).collect() + } +} diff --git a/test-initrd/id_ecdsa b/test-initrd/id_ecdsa new file mode 100644 index 0000000..11b52c0 --- /dev/null +++ b/test-initrd/id_ecdsa @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSlevO48OA0/yB8zTP1naijVU3wsB0c +4s6Jc/8y7YwNq51/SlieeGeRFcCSke5Z67rNX2JFiv+tsGQB6TczZm/6AAAAqDU5b2c1OW +9nAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKV687jw4DT/IHzN +M/WdqKNVTfCwHRzizolz/zLtjA2rnX9KWJ54Z5EVwJKR7lnrus1fYkWK/62wZAHpNzNmb/ +oAAAAhANenGXlP1ZPgcyb/+O57MbpGDOZS4e7UNiyvkD8I3hNnAAAACW53cmtAbndyawEC +AwQFBg== +-----END OPENSSH PRIVATE KEY----- diff --git a/test-initrd/id_ed25519 b/test-initrd/id_ed25519 new file mode 100644 index 0000000..7c91e4c --- /dev/null +++ b/test-initrd/id_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCCMX0zm3Fi7vXWevHuJvzU30l5F06NAC2qloUewVSoDQAAAJDnq/y456v8 +uAAAAAtzc2gtZWQyNTUxOQAAACCCMX0zm3Fi7vXWevHuJvzU30l5F06NAC2qloUewVSoDQ +AAAEDXYq507MsjSN34Qw87guf3d5D4Dt2IrF788CeBcYSNe4IxfTObcWLu9dZ68e4m/NTf +SXkXTo0ALaqWhR7BVKgNAAAACW53cmtAbndyawECAwQ= +-----END OPENSSH PRIVATE KEY----- diff --git a/test-initrd/id_rsa.pub b/test-initrd/id_rsa.pub deleted file mode 100644 index 3abde42..0000000 --- a/test-initrd/id_rsa.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDLo+mv+0GJViwFiK14bAw8j4HYFIGf4I/fpmIqzHohf70TT+cYqAKSHBYV+uYjNLYQib6Ii/thPexeypw73pq7Z7CHvubnFQUiuqJ7lzXeV4146f5eHL76/TvTmKthS4FVFta6SlCOE6unR6q8HM52VSijavtoX3musxugCwhaHmBNOdUJcNIRCIey8QaOztPK05dDrOZhMQJgSST/BRaNrtY0/xRmJo+5TbQeyjyDuQdK2EoS51QMWzsT/LH6drQBgKd8RRHqjEhscfaV2CmdfuVO/liEdW82epMgFCGYtMetP/rs3bPkC90ULxPZSytaz7d5ux1dvSgrPHzX4306k/GP3d6EvOedy4IKAB53J7lebvrRI5pTVPZvd/RsSGGxUIwjf2Y8TF5nbC2d2D5Oauqfevn29veIRh8mq+AsJMnkvUFwVRN+6WDsZ+F82+AGaCKJFNQLYbRKtXW0zzi+wTsnLJNwlpevRf61SCxehSqeVfnc4TDsAGyRa9nSbR8= test@novit.io diff --git a/test-initrd/modules.sqfs b/test-initrd/modules.sqfs index 46a0317..c30f395 100644 Binary files a/test-initrd/modules.sqfs and b/test-initrd/modules.sqfs differ diff --git a/test-kernel b/test-kernel index fb09b86..8c090b4 100644 Binary files a/test-kernel and b/test-kernel differ