diff --git a/.gitignore b/.gitignore index ccc36f7..97a1f70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,51 +1,38 @@ -# ---> C++ -# Prerequisites *.d -# Compiled Object files *.slo *.lo *.o *.obj -# Precompiled Headers *.gch *.pch -# Linker files *.ilk -# Debugger Files *.pdb -# Compiled Dynamic libraries *.so *.dylib *.dll *.so.* - -# Fortran module files *.mod *.smod -# Compiled Static libraries *.lai *.la *.a *.lib -# Executables *.exe *.out *.app -# Build directories build/ Build/ build-*/ -# CMake generated files CMakeFiles/ CMakeCache.txt cmake_install.cmake @@ -53,18 +40,16 @@ Makefile install_manifest.txt compile_commands.json -# Temporary files +__pycache__ + *.tmp *.log *.bak *.swp -# vcpkg vcpkg_installed/ -# debug information files *.dwo -# test output & cache Testing/ .cache/ diff --git a/README.md b/README.md index 8bab7e1..5afa5cb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,96 @@ # bajia -an init system \ No newline at end of file +an init system (PID 1) for embedded / VM targets, written in C++20 and +configured with a small declarative language inspired by Android's init `.rc` +format. + +## Status + +- `.rc` config parser (services + on-trigger action blocks) +- Supervisor event loop built on `signalfd` + `epoll` +- service spawn / reap / respawn (per-service restart policy) +- action commands: `start`, `stop`, `restart`, `exec`, `mkdir`, `chmod`, + `chown`, `setenv`, `write`, `symlink`, `mount`, `log` +- logger with a ring buffer that flushes to the console once available + +roadmap: + +- user/group privilege drop (`user`, `group`, supplementary groups) +- dependency ordering between services +- `SIGCHLD` crash-window limiting (rate-limited restarts) +- property triggers (`property:=`) and `setprop`/`getprop` +- per-service logging to files +- `reboot`/`poweroff` path with ordered unmount +- `SIGHUP` config reload +- SELinux + +## building + +requires a C++20 compiler and [Ninja](https://ninja-build.org/) + +```sh +python3 configure.py # generates build/build.ninja +ninja -C build # produces build/bajia +``` + +`configure.py` also writes a thin `Makefile` convenience wrapper +(`make`, `make clean`, `make format`, `--asan`, `--debug`). + +## running + +As a real init, the kernel must launch it as PID 1: + +``` +init=/path/to/bajia +``` + +Or by hand against a config (useful for development, may not behave like a +real boot). bajia normally refuses to start unless it is PID 1; pass +`--run-as-user` to override: + +```sh +./build/bajia --run-as-user etc/init.rc +``` + +If no files are given it looks for `/etc/bajia/init.rc`. + +## configuration language + +See [`etc/init.rc`](etc/init.rc) for a complete example. + +### services + +```rc +service NAME /path/to/exe [args...] + user root|other # privilege level (drop, planned) + group GROUP [GROUP...] + oneshot # run once and exit, never respawn + disabled # not started by the boot sequence + console # bind stdio to /dev/console + class NAME # grouping (default "default") + respawn never|on-failure|always # restart policy (default always) + crash-threshold N # restarts allowed per window + crash-window SECS + setenv K=V # extra environment (repeatable) + cwd /path +``` + +### actions + +```rc +on TRIGGER + start NAME | stop NAME | restart NAME + exec /cmd args... + mkdir PATH [mode] + chmod PATH mode + chown PATH uid gid + setenv K V + write PATH CONTENT + symlink TARGET LINK + mount SOURCE TARGET FSTYPE + log message +``` + +boot triggers fire in order: `early-init`, `init`, `boot`. `shutdown` is +reserved (planned wiring to the signal path). property/`service-*` triggers +are on the roadmap. diff --git a/configure.py b/configure.py new file mode 100644 index 0000000..a7e4e3f --- /dev/null +++ b/configure.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""generates a Ninja build file + +usage: + python3 configure.py + python3 configure.py --asan # enable address/undefined sanitizers + python3 configure.py --debug # -O0 instead of -O2 +""" +import argparse +import os +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "src" +BUILD = ROOT / "build" + +CXX = os.environ.get("CXX", "g++") +CXXFLAGS = ["-std=c++20", "-O2", "-g"] +WARNINGS = ["-Wall", "-Wextra", "-Wpedantic", "-Wshadow", "-Wconversion", "-Wnull-dereference"] +linkflags = [] + +def discover_sources(): + return sorted( + p.name for p in SRC.glob("*.cpp") + if not p.name.endswith("_main.cpp") and not p.name.startswith(".") + ) + +def write_makefile_convenience(): + make = ROOT / "Makefile" + make.write_text( + """# convenience wrapper around configure.py + ninja. +# +# make -> configure + build +# make configure -> regenerate build.ninja +# make clean -> remove build dir +# make format -> clang-format all sources (if available) +# +.PHONY: all configure clean format +all: configure +\t@ninja -C build + +configure: +\t@python3 configure.py + +clean: +\t@rm -rf build + +format: +\t@command -v clang-format >/dev/null && clang-format -i src/*.cpp src/*.hpp || true +""", + encoding="utf-8", + ) + + +def emit_ninja(cxx, cxxflags, dst): + sources = discover_sources() + objs = ["obj/" + Path(s).stem + ".o" for s in sources] + target = "bajia" + + rule_cxx = ( + "rule cxx\n" + " command = {cxx} {cxxflags} -MMD -MF $out.d -c $in -o $out\n" + " depfile = $out.d\n" + " deps = gcc\n" + " description = CXX $out\n" + ).format(cxx=cxx, cxxflags=" ".join(cxxflags)) + + rule_link = ( + "rule link\n" + " command = {cxx} {ldflags} $in -o $out -lpthread\n" + " description = LINK $out\n" + ).format(cxx=cxx, ldflags=" ".join(linkflags)) + + lines = ["# generated by configure.py -- do not edit", "ninja_required_version = 1.8", ""] + lines.append(rule_cxx) + lines.append(rule_link) + lines.append('build {target}: link {objs}'.format(target=target, objs=" ".join(objs))) + lines.append("") + for o, s in zip(objs, sources): + lines.append('build {o}: cxx {src}/{s}'.format(o=o, src=SRC, s=s)) + lines.append("") + lines.append('build all: phony {target}'.format(target=target)) + lines.append("default all") + lines.append("") + + (BUILD / "obj").mkdir(exist_ok=True) + dst.write_text("\n".join(lines) + "\n", encoding="utf-8") + +def parse_args(): + p = argparse.ArgumentParser(description="Configure bajia build (generates build.ninja)") + p.add_argument("--asan", action="store_true", help="enable address + UB sanitizers") + p.add_argument("--debug", action="store_true", help="disable -O2, enable -O0") + p.add_argument("--clean", action="store_true", help="remove build dir") + return p.parse_args() + +def main(): + args = parse_args() + if args.clean: + shutil.rmtree(BUILD, ignore_errors=True) + print("removed build/") + return 0 + + if not shutil.which("ninja"): + print("error: ninja not found in PATH", file=sys.stderr) + return 1 + + BUILD.mkdir(exist_ok=True) + + cxxflags = list(CXXFLAGS) + global linkflags + linkflags = [] + if args.debug: + cxxflags = [f for f in cxxflags if f != "-O2"] + cxxflags += ["-O0"] + if args.asan: + cxxflags += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + linkflags += ["-fsanitize=address,undefined"] + + cxxflags = [f for f in cxxflags if f not in WARNINGS] + cxxflags = WARNINGS + cxxflags + + emit_ninja(CXX, cxxflags, BUILD / "build.ninja") + write_makefile_convenience() + + print(f"configured {len(discover_sources())} sources -> build/build.ninja") + print("run: ninja -C build") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/etc/init.rc b/etc/init.rc new file mode 100644 index 0000000..c995b57 --- /dev/null +++ b/etc/init.rc @@ -0,0 +1,54 @@ +# bajia init.rc - example configuration for an embedded/VM system. +# +# constructs: +# service NAME /path/to/exe [args...] +# user|group|oneshot|disabled|console|class|respawn|crash-*|setenv|cwd +# +# on TRIGGER +# start NAME | stop NAME | restart NAME +# exec /cmd args... (run synchronously, wait to finish) +# mkdir PATH [mode] +# chmod PATH mode +# chown PATH uid gid +# setenv K V +# write PATH CONTENT +# symlink TARGET LINK +# mount SOURCE TARGET FSTYPE +# log message... +# +# standard trigger sequence run at boot: early-init, init, boot. +# future: property:=, service-started:. + +on early-init + mount proc /proc proc + mount sysfs /sys sysfs + mount devtmpfs /dev devtmpfs + mkdir /dev/pts 0755 + mount devpts /dev/pts devpts + mkdir /run 0755 + mount tmpfs /run tmpfs + +on init + exec /sbin/modprobe virtio_rng + write /proc/sys/kernel/hostname bajia + log **** bajia init on-line **** + +on boot + start console + start watchdog + +# the console service: bind stdio to /dev/console, always respawn. +service console /sbin/getty -L ttyS0 115200 vt100 + class core + console + user root + +# a long-lived example daemon. respawning is the default (always). +service watchdog /usr/sbin/watchdog + class core + respawn always + +# a one-shot job: runs once, exits, never respawns. +service boot-logo /usr/bin/show-boot-logo + class late + oneshot diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..e455c8c --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,188 @@ +// config.cpp - parser for the bajia .rc language. +#include "config.hpp" + +#include + +namespace bajia { + +namespace { + +// tokenize a line honoring double-quoted strings as single tokens. +// both '#' (unquoted) and ';' begin a comment to end of line +std::vector tokenize(const std::string& line) { + std::vector out; + std::string cur; + + bool in_q = false; + bool need_quote_close = false; + + for (size_t i = 0; i < line.size(); ++i) { + char c = line[i]; + if (in_q) { + if (c == '"') { + in_q = false; + need_quote_close = false; + } else if (c == '\\' && i + 1 < line.size()) { + cur += line[++i]; + } else { + cur += c; + } + continue; + } + if (c == '"') { + in_q = true; + need_quote_close = true; + continue; + } + if (c == '#' || c == ';') { + break; // comment to end of line + } + if (std::isspace(static_cast(c))) { + if (!cur.empty()) { + out.push_back(cur); + cur.clear(); + } + continue; + } + cur += c; + } + if (in_q) { + // unterminated quote: best effort, keep what we have. + if (!cur.empty()) out.push_back(cur); + } else if (!cur.empty()) { + out.push_back(cur); + } + (void)need_quote_close; + return out; +} + +RespawnPolicy parse_respawn(const std::string& s) { + if (s == "always") return RespawnPolicy::Always; + if (s == "on-failure") return RespawnPolicy::OnFailure; + return RespawnPolicy::Never; +} + +} // namespace + +Service* Config::find_service(const std::string& name) { + for (auto& s : services) { + if (s.name == name) return &s; + } + return nullptr; +} + +const Service* Config::find_service(const std::string& name) const { + for (auto& s : services) { + if (s.name == name) return &s; + } + return nullptr; +} + +// public entry point +Config parse_config(const std::vector& files) { + Config cfg; + int line = 0; + std::string section_kind; // "service" or "action" + Service* cur_svc = nullptr; // service being configured + Action* cur_act = nullptr; // action being configured + + for (const auto& file : files) { + std::ifstream in(file); + if (!in) { + throw std::runtime_error("cannot open config file: " + file); + } + std::string raw; + line = 0; + section_kind.clear(); + cur_svc = nullptr; + cur_act = nullptr; + + while (std::getline(in, raw)) { + ++line; + auto toks = tokenize(raw); + if (toks.empty()) continue; + + std::string first = toks[0]; + size_t indent = raw.find_first_not_of(" \t"); + + if (first == "service" && indent == 0) { + if (toks.size() < 3) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": 'service' requires name + executable"); + } + Service svc; + svc.name = toks[1]; + svc.args.assign(toks.begin() + 2, toks.end()); + cfg.services.push_back(std::move(svc)); + cur_svc = &cfg.services.back(); + cur_act = nullptr; + section_kind = "service"; + continue; + } + + if (first == "on") { + if (toks.size() < 2) { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": 'on' requires a trigger"); + } + cfg.actions.push_back(Action{toks[1], {}}); + cur_act = &cfg.actions.back(); + cur_svc = nullptr; + section_kind = "action"; + continue; + } + + // service options (must already be inside a service section). + if (section_kind == "service" && cur_svc) { + if (first == "user" && toks.size() >= 2) cur_svc->uid = toks[1]; + else if (first == "group" && toks.size() >= 2) { + cur_svc->gid = toks[1]; + for (size_t i = 2; i < toks.size(); ++i) cur_svc->groups.push_back(toks[i]); + } + else if (first == "oneshot") cur_svc->oneshot = true; + else if (first == "disabled") cur_svc->disabled = true; + else if (first == "console") cur_svc->console = true; + else if (first == "class" && toks.size() >= 2) cur_svc->service_class = toks[1]; + else if (first == "respawn" && toks.size() >= 2) + cur_svc->respawn = parse_respawn(toks[1]); + else if (first == "crash-threshold" && toks.size() >= 2) + cur_svc->crash_threshold = std::stoi(toks[1]); + else if (first == "crash-window" && toks.size() >= 2) + cur_svc->crash_window_secs = std::stoi(toks[1]); + else if (first == "setenv" && toks.size() >= 2) cur_svc->env.push_back(toks[1]); + else if (first == "cwd" && toks.size() >= 2) cur_svc->cwd = toks[1]; + continue; + } + + // action command (must be inside an action section). + if (section_kind == "action" && cur_act) { + Command cmd; + if (first == "start" && toks.size() >= 2) cmd.kind = Command::Kind::Start; + else if (first == "stop" && toks.size() >= 2) cmd.kind = Command::Kind::Stop; + else if (first == "restart" && toks.size() >= 2) cmd.kind = Command::Kind::Restart; + else if (first == "exec") cmd.kind = Command::Kind::Exec; + else if (first == "mkdir") cmd.kind = Command::Kind::Mkdir; + else if (first == "chmod") cmd.kind = Command::Kind::Chmod; + else if (first == "chown") cmd.kind = Command::Kind::Chown; + else if (first == "setenv") cmd.kind = Command::Kind::Setenv; + else if (first == "write") cmd.kind = Command::Kind::Write; + else if (first == "symlink") cmd.kind = Command::Kind::Symlink; + else if (first == "mount") cmd.kind = Command::Kind::Mount; + else if (first == "log") cmd.kind = Command::Kind::Log; + else { + throw std::runtime_error(file + ":" + std::to_string(line) + + ": unknown action command '" + first + "'"); + } + cmd.args.assign(toks.begin() + 1, toks.end()); + cur_act->commands.push_back(std::move(cmd)); + continue; + } + + throw std::runtime_error(file + ":" + std::to_string(line) + + ": unexpected directive '" + first + "'"); + } + } + return cfg; +} + +} // namespace bajia diff --git a/src/config.hpp b/src/config.hpp new file mode 100644 index 0000000..3ba3914 --- /dev/null +++ b/src/config.hpp @@ -0,0 +1,107 @@ +// config.hpp - data model for the bajia .rc language. +// +// The language is a small, embedded-oriented dialect inspired by Android init. +// Two top-level constructs: +// +// service NAME /path/to/exec args... +// user root +// group root +// oneshot +// disabled +// class main +// respawn never|on-failure|always +// console +// +// on TRIGGER +// start NAME +// exec /path/to/cmd args... +// mkdir /path mode +// setenv K V +// +// TRIGGER events: early-init, init, boot, shutdown, property:=, +// service-started:, service-stopped:. +#pragma once + +#include +#include +#include +#include +#include + +namespace bajia { + +// -------------------------------------------------------------------------- +// Service +// -------------------------------------------------------------------------- +enum class RespawnPolicy { + Never, + OnFailure, + Always, +}; + +struct Service { + std::string name; + std::vector args; // executable path + arguments + std::string cwd = "/"; + std::string uid = "root"; // resolved in supervisor + std::string gid = "root"; + std::vector groups; + bool oneshot = false; // run once, don't keep alive + bool disabled = false; // not started automatically + bool console = false; // bind stdio to the console + std::string service_class = "default"; + RespawnPolicy respawn = RespawnPolicy::Always; + int crash_threshold = 4; // max restarts within window + int crash_window_secs = 30; // before giving up + std::vector env; // "K=V" pairs + + // Runtime state + int pid = 0; + int exit_code = 0; + bool running = false; +}; + +// -------------------------------------------------------------------------- +// Action: a list of commands to run when a trigger fires. +// -------------------------------------------------------------------------- +struct Command { + enum class Kind { + Start, + Stop, + Restart, + Exec, // run a synchronous command to completion + Mkdir, + Chmod, + Chown, + Setenv, + Write, + Symlink, + Mount, + Log, + }; + Kind kind; + std::vector args; // command-specific arguments +}; + +struct Action { + std::string trigger; // e.g. "boot", "early-init" + std::vector commands; +}; + +// -------------------------------------------------------------------------- +// Config: everything parsed from all loaded .rc files. +// -------------------------------------------------------------------------- +struct Config { + std::vector services; + std::vector actions; + std::string hostname; + + Service* find_service(const std::string& name); + const Service* find_service(const std::string& name) const; +}; + +// Parse a set of .rc files into a Config. Throws std::runtime_error on +// malformed input (reported with file:line context). +Config parse_config(const std::vector& files); + +} // namespace bajia diff --git a/src/logger.cpp b/src/logger.cpp new file mode 100644 index 0000000..cbf3e66 --- /dev/null +++ b/src/logger.cpp @@ -0,0 +1,92 @@ +// logger.cpp - implementation of the pid-1 logger. +#include "logger.hpp" + +#include +#include +#include +#include +#include +#include + +namespace bajia { + +namespace { + +std::mutex g_lock; +int g_fd = -1; // console fd (>=0 when open) +std::array g_ring; +size_t g_ring_pos = 0; +size_t g_ring_count = 0; +LogLevel g_min_level = LogLevel::Info; + +const char* level_name(LogLevel l) { + switch (l) { + case LogLevel::Debug: return "DBG"; + case LogLevel::Info: return "INF"; + case LogLevel::Warn: return "WRN"; + case LogLevel::Err: return "ERR"; + } + return "???"; +} + +void write_all(const std::string& s) { + if (g_fd >= 0) { + size_t off = 0; + while (off < s.size()) { + ssize_t n = ::write(g_fd, s.data() + off, s.size() - off); + if (n < 0) break; + off += static_cast(n); + } + } else { + ::write(STDERR_FILENO, s.data(), s.size()); + } +} + +std::string timestamp() { + std::time_t t = std::time(nullptr); + std::tm tm{}; + localtime_r(&t, &tm); + char buf[32]; + std::strftime(buf, sizeof buf, "%H:%M:%S", &tm); + return buf; +} + +} // namespace + +void log_init(const std::string& console_path, LogLevel min_level) { + g_min_level = min_level; + g_fd = ::open(console_path.c_str(), O_WRONLY | O_NOCTTY | O_CLOEXEC); + if (g_fd >= 0) { + // flush buffered boot messages from the ring. + std::lock_guard lk(g_lock); + size_t start = (g_ring_count < g_ring.size()) ? 0 : g_ring_pos; + size_t n = std::min(g_ring_count, g_ring.size()); + for (size_t i = 0; i < n; ++i) { + write_all(g_ring[(start + i) % g_ring.size()]); + } + g_ring_count = 0; + } +} + +void log_set_level(LogLevel level) { g_min_level = level; } + +void log_msg(LogLevel level, const std::string& tag, const std::string& msg) { + if (level < g_min_level) return; + std::string line = "[" + timestamp() + "] [" + level_name(level) + "] " + tag + ": " + + msg + "\n"; + std::lock_guard lk(g_lock); + if (g_fd < 0 && g_ring_count < g_ring.size()) { + g_ring[g_ring_pos] = line; + g_ring_pos = (g_ring_pos + 1) % g_ring.size(); + ++g_ring_count; + // drop oldest to keep ordering if ring ever overflows + if (g_ring_count == g_ring.size()) { + g_ring[g_ring_pos] = line; + g_ring_pos = (g_ring_pos + 1) % g_ring.size(); + } + return; + } + write_all(line); +} + +} // namespace bajia diff --git a/src/logger.hpp b/src/logger.hpp new file mode 100644 index 0000000..fafda3b --- /dev/null +++ b/src/logger.hpp @@ -0,0 +1,27 @@ +// logger.hpp - minimal kernel-log / console logger for PID 1. +// +// Logs go to the console (/dev/console) if it can be opened, otherwise +// fall back to stderr. A ring buffer keeps boot messages in memory until +// the console is ready. +#pragma once + +#include +#include + +namespace bajia { + +enum class LogLevel { Debug = 0, Info = 1, Warn = 2, Err = 3 }; + +void log_init(const std::string& console_path = "/dev/console", LogLevel min_level = LogLevel::Info); +void log_set_level(LogLevel level); + +void log_msg(LogLevel level, const std::string& tag, const std::string& msg); + +template +void log_info(const std::string& tag, Args&&... args) { + std::string buf; + (buf += ... += std::forward(args)); + log_msg(LogLevel::Info, tag, buf); +} + +} // namespace bajia diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..05da2e2 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,90 @@ +// main.cpp - bajia init system entry point (PID 1). +// +// loads .rc config files and hands control to the Supervisor, which never +// returns. on a real system this binary should be passed to the kernel as +// `init=/path/to/bajia`. +#include "config.hpp" +#include "logger.hpp" +#include "supervisor.hpp" + +#include +#include +#include +#include +#include + +using namespace bajia; + +namespace { + +void usage(const char* argv0) { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg) + ::fprintf(stderr, + "usage: %s [--run-as-user] [config.rc ...]\n" + " if no files are given, reads /etc/bajia/init.rc if present.\n" + " as PID 1 this usually means the kernel passed init=%s.\n" + " refuses to start unless it is PID 1; --run-as-user overrides\n" + " that check for development runs.\n", + argv0, argv0); +} + +} // namespace + +int main(int argc, char** argv) { + std::vector files; + bool run_as_user = false; + + // PID 1 note: the kernel may pass extra args after the init program name. + for (int i = 1; i < argc; ++i) { + if (argv[i][0] == '-') { + if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 0; + } + if (std::strcmp(argv[i], "--run-as-user") == 0) { + run_as_user = true; + continue; + } + usage(argv[0]); + return 2; + } + files.emplace_back(argv[i]); + } + + if (::getpid() != 1 && !run_as_user) { + ::fprintf(stderr, + "bajia: refusing to run as pid %d (not PID 1). bajia is an " + "init system and would re-fire boot triggers, mount " + "filesystems and spawn services on top of a running system. " + "Launch it via the kernel (init=...), or pass --run-as-user " + "for a development run.\n", + ::getpid()); + return 1; + } + + if (files.empty()) { + // default: prefer the single init.rc, and also load /etc/bajia.d/*.rc. + if (::access("/etc/bajia/init.rc", R_OK) == 0) files.emplace_back("/etc/bajia/init.rc"); + if (files.empty()) { + ::fprintf(stderr, "bajia: no config given and /etc/bajia/init.rc not found.\n"); + return 1; + } + } + + Config config; + try { + config = parse_config(files); + } catch (const std::exception& e) { + ::fprintf(stderr, "bajia: config error: %s\n", e.what()); + return 1; + } + + // logger targets the console; falls back to stderr until the console is ready. + log_init("/dev/console", LogLevel::Info); + log_info("init", "loaded ", std::to_string(files.size()), " config file(s), ", + std::to_string(config.services.size()), " services, ", + std::to_string(config.actions.size()), " actions"); + + Supervisor supervisor(std::move(config)); + supervisor.run(); // [[noreturn]] +} diff --git a/src/supervisor.cpp b/src/supervisor.cpp new file mode 100644 index 0000000..6116674 --- /dev/null +++ b/src/supervisor.cpp @@ -0,0 +1,512 @@ +// supervisor.cpp - PID 1 supervisor +#include "supervisor.hpp" + +#include "logger.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace bajia { + +namespace { + +constexpr const char* kTag = "supv"; + +// shutdown timing +constexpr int kStopGraceSecs = 5; // wait this long for a clean SIGTERM exit +constexpr int kKillGraceSecs = 2; // then this long after SIGKILL before giving up + +// console fd shared with spawned services flagged `console`. +int g_open_console_fd = -1; + +std::string join(const std::vector& v, const std::string& sep) { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { + if (i) out += sep; + out += v[i]; + } + return out; +} + +} // namespace + +Supervisor::Supervisor(Config config) : config_(std::move(config)) {} + +Supervisor::~Supervisor() { + if (sigfd_ >= 0) ::close(sigfd_); + if (epfd_ >= 0) ::close(epfd_); +} + +void Supervisor::setup_signals() { + sigset_t mask; + sigemptyset(&mask); + + // block these for ALL threads/processes; signalfd delivers them to us. + sigaddset(&mask, SIGCHLD); + sigaddset(&mask, SIGTERM); + sigaddset(&mask, SIGINT); + sigaddset(&mask, SIGHUP); + sigaddset(&mask, SIGUSR1); + sigaddset(&mask, SIGQUIT); + if (sigprocmask(SIG_BLOCK, &mask, nullptr) != 0) { + log_info(kTag, "sigprocmask failed: ", std::strerror(errno)); + } + + sigfd_ = ::signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK); + if (sigfd_ < 0) { + log_info(kTag, "signalfd failed: ", std::strerror(errno)); + _exit(1); + } + + epfd_ = ::epoll_create1(EPOLL_CLOEXEC); + if (epfd_ < 0) { + log_info(kTag, "epoll_create1 failed: ", std::strerror(errno)); + _exit(1); + } + + struct epoll_event ev {}; + ev.events = EPOLLIN; + ev.data.fd = sigfd_; + if (::epoll_ctl(epfd_, EPOLL_CTL_ADD, sigfd_, &ev) != 0) { + log_info(kTag, "epoll_ctl failed: ", std::strerror(errno)); + _exit(1); + } +} + +void Supervisor::spawn_service(Service& svc, bool missing_ok) { + if (svc.running || svc.pid > 0) { + log_info(kTag, svc.name, " already running"); + return; + } + if (svc.args.empty()) { + log_info(kTag, "service ", svc.name, " has no executable"); + return; + } + + if (::access(svc.args[0].c_str(), X_OK) != 0) { + if (missing_ok) { + log_info(kTag, svc.name, ": executable missing (", svc.args[0], "), ignored"); + return; + } + log_info(kTag, svc.name, ": cannot exec ", svc.args[0], ": ", std::strerror(errno)); + return; + } + + pid_t pid = ::fork(); + if (pid < 0) { + log_info(kTag, svc.name, ": fork failed: ", std::strerror(errno)); + return; + } + + if (pid == 0) { + // child + if (svc.console && g_open_console_fd >= 0) { + ::dup2(g_open_console_fd, 0); + ::dup2(g_open_console_fd, 1); + ::dup2(g_open_console_fd, 2); + } + + if (::chdir(svc.cwd.c_str()) != 0) { + _exit(126); + } + + // environment: inherit, then apply K=V entries. + std::vector kvs = svc.env; + std::vector envp; + for (char** e = environ; e && *e; ++e) envp.push_back(*e); + for (auto& kv : kvs) envp.push_back(const_cast(kv.c_str())); + envp.push_back(nullptr); + + std::vector argv; + for (auto& a : svc.args) argv.push_back(const_cast(a.c_str())); + argv.push_back(nullptr); + + // uid/gid handling intentionally left to a drop-privileges pass; + // for a first version we run as-is. Placeholder to avoid unused warn. + (void)svc.uid; + (void)svc.gid; + (void)svc.groups; + + ::execvpe(argv[0], argv.data(), envp.data()); + // exec failed in child + _exit(127); + } + + // parent + svc.pid = pid; + svc.running = true; + log_info(kTag, svc.name, " started (pid ", std::to_string(pid), ")"); +} + +void Supervisor::start_service(const std::string& name) { + Service* svc = config_.find_service(name); + if (!svc) { + log_info(kTag, "start ", name, ": no such service"); + return; + } + if (svc->running) return; + spawn_service(*svc, false); +} + +void Supervisor::stop_service(const std::string& name, bool kill) { + Service* svc = config_.find_service(name); + if (!svc || !svc->running) return; + if (svc->pid > 0) { + ::kill(svc->pid, kill ? SIGKILL : SIGTERM); + } +} + +void Supervisor::restart_service(const std::string& name) { + Service* svc = config_.find_service(name); + if (!svc) return; + if (svc->running && svc->pid > 0) { + ::kill(svc->pid, SIGTERM); + // It will be respawned by reap logic for non-oneshot services; for + // simplicity, mark for immediate respawn below. + } + // If not running, start now. + if (!svc->running) spawn_service(*svc, false); +} + +void Supervisor::reap_children() { + int status; + pid_t pid; + while ((pid = ::waitpid(-1, &status, WNOHANG)) > 0) { + // Find the service this pid belongs to. + bool matched = false; + for (auto& svc : config_.services) { + if (svc.pid == pid) { + matched = true; + int code = WIFEXITED(status) ? WEXITSTATUS(status) + : (WIFSIGNALED(status) ? 128 + WTERMSIG(status) : -1); + svc.exit_code = code; + svc.pid = 0; + svc.running = false; + log_info(kTag, svc.name, " exited with code ", std::to_string(code)); + + bool success = (code == 0); + if (svc.oneshot) { + // oneshot services terminate on their own; never respawn. + continue; + } + bool should_respawn = false; + switch (svc.respawn) { + case RespawnPolicy::Always: should_respawn = true; break; + case RespawnPolicy::OnFailure: should_respawn = !success; break; + case RespawnPolicy::Never: should_respawn = false; break; + } + if (should_respawn) { + if (shutdown_requested_) break; + spawn_service(svc, true); + } + break; + } + } + (void)matched; // keep matched to avoid unused warning + + // for pids not matching a tracked service: they're orphans reparented + // to us; already reaped here, so nothing more to do. + } +} + +void Supervisor::run_exec_command(const Command& cmd) { + // synchronously run an action 'exec' and wait for it to complete. + if (cmd.args.empty()) return; + pid_t pid = ::fork(); + if (pid < 0) return; + if (pid == 0) { + std::vector argv; + for (auto& a : cmd.args) argv.push_back(const_cast(a.c_str())); + argv.push_back(nullptr); + ::execvpe(argv[0], argv.data(), environ); + _exit(127); + } + int status; + ::waitpid(pid, &status, 0); + log_info(kTag, "action exec ", join(cmd.args, " "), " -> ", + WIFEXITED(status) ? std::to_string(WEXITSTATUS(status)) : "signal"); +} + +void Supervisor::run_command(Command& cmd) { + using K = Command::Kind; + switch (cmd.kind) { + case K::Start: + if (!cmd.args.empty()) start_service(cmd.args[0]); + break; + case K::Stop: + if (!cmd.args.empty()) stop_service(cmd.args[0], true); + break; + case K::Restart: + if (!cmd.args.empty()) restart_service(cmd.args[0]); + break; + case K::Exec: + run_exec_command(cmd); + break; + case K::Mkdir: + if (cmd.args.size() >= 1) { + mode_t m = cmd.args.size() >= 2 ? static_cast(std::stoul(cmd.args[1], nullptr, 8)) : 0755; + ::mkdir(cmd.args[0].c_str(), m); + } + break; + case K::Chmod: + if (cmd.args.size() >= 2) ::chmod(cmd.args[0].c_str(), + static_cast(std::stoul(cmd.args[1], nullptr, 8))); + break; + case K::Chown: + if (cmd.args.size() >= 3) { + uid_t uid = static_cast(std::stoul(cmd.args[1])); + gid_t gid = static_cast(std::stoul(cmd.args[2])); + ::chown(cmd.args[0].c_str(), uid, gid); + } + break; + case K::Setenv: + if (cmd.args.size() >= 1) ::setenv(cmd.args[0].c_str(), cmd.args[1].c_str(), 1); + break; + case K::Write: { + if (cmd.args.size() >= 2) { + int fd = ::open(cmd.args[0].c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) { + ::write(fd, cmd.args[1].c_str(), cmd.args[1].size()); + ::close(fd); + } + } + break; + } + case K::Symlink: + if (cmd.args.size() >= 2) ::symlink(cmd.args[0].c_str(), cmd.args[1].c_str()); + break; + case K::Mount: + if (cmd.args.size() >= 3) ::mount(cmd.args[0].c_str(), cmd.args[1].c_str(), + cmd.args[2].c_str(), 0, nullptr); + break; + case K::Log: + log_info(kTag, "action: ", join(cmd.args, " ")); + break; + } +} + +void Supervisor::execute_action(Action& action) { + log_info(kTag, "trigger: ", action.trigger); + for (auto& cmd : action.commands) { + run_command(cmd); + } +} + +// The console fd, opened once PID1 realizes it's on a real console. Provided +// so spawn_service can rebind stdio for services flagged `console`. +void Supervisor::open_console() { + if (g_open_console_fd >= 0) return; + g_open_console_fd = ::open("/dev/console", O_RDWR | O_NOCTTY | O_CLOEXEC); +} + +void Supervisor::begin_shutdown(ShutdownKind kind) { + if (shutdown_requested_) return; // already winding down + shutdown_requested_ = true; + shutdown_kind_ = kind; + shutdown_state_ = ShutdownState::FiringActions; + log_info(kTag, "shutdown requested: ", + kind == ShutdownKind::Reboot ? "reboot" : "poweroff"); +} + +bool Supervisor::any_running() const { + for (const auto& svc : config_.services) { + if (svc.running) return true; + } + return false; +} + +int Supervisor::shutdown_timeout_ms() const { + // millis until the next state transition, or -1 for "wait forever". + if (shutdown_state_ == ShutdownState::Running) return -1; + const auto now = std::chrono::steady_clock::now(); + if (now >= shutdown_deadline_) return 0; + const auto ms = std::chrono::duration_cast( + shutdown_deadline_ - now).count(); + return ms >= INT_MAX ? INT_MAX : static_cast(ms); +} + +void Supervisor::unmount_filesystems() { + FILE* f = ::fopen("/proc/self/mounts", "r"); + if (!f) { + log_info(kTag, "unmount: cannot open /proc/self/mounts: ", std::strerror(errno)); + return; + } + std::vector mounts; // mountpoints in mount order + char line[4096]; + while (::fgets(line, sizeof line, f)) { + std::vector toks; + char* save = nullptr; + for (char* p = ::strtok_r(line, " \t\n", &save); p; + p = ::strtok_r(nullptr, " \t\n", &save)) { + toks.emplace_back(p); + } + if (toks.size() >= 2 && toks[1] != "/") mounts.push_back(toks[1]); + } + ::fclose(f); + for (auto it = mounts.rbegin(); it != mounts.rend(); ++it) { // deepest last + if (::umount2(it->c_str(), MNT_DETACH) != 0) { + log_info(kTag, "unmount ", *it, ": ", std::strerror(errno)); + } else { + log_info(kTag, "unmounted ", *it); + } + } +} + +bool Supervisor::advance_shutdown() { + switch (shutdown_state_) { + case ShutdownState::Running: + return false; + + case ShutdownState::FiringActions: + log_info(kTag, "firing shutdown actions"); + for (auto& action : config_.actions) { + if (action.trigger == "shutdown") execute_action(action); + } + for (auto& svc : config_.services) { + if (svc.running && svc.pid > 0) { + log_info(kTag, "stopping ", svc.name, " (pid ", + std::to_string(svc.pid), ")"); + ::kill(svc.pid, SIGTERM); + } + } + shutdown_deadline_ = std::chrono::steady_clock::now() + + std::chrono::seconds(kStopGraceSecs); + shutdown_state_ = ShutdownState::StoppingServices; + return false; + + case ShutdownState::StoppingServices: + if (!any_running()) { + shutdown_state_ = ShutdownState::Finalizing; + return false; + } + if (std::chrono::steady_clock::now() >= shutdown_deadline_) { + log_info(kTag, "grace elapsed; forcing kill"); + for (auto& svc : config_.services) { + if (svc.running && svc.pid > 0) { + log_info(kTag, "killing ", svc.name, " (pid ", + std::to_string(svc.pid), ")"); + ::kill(svc.pid, SIGKILL); + } + } + shutdown_deadline_ = std::chrono::steady_clock::now() + + std::chrono::seconds(kKillGraceSecs); + shutdown_state_ = ShutdownState::ForcingKill; + } + return false; + + case ShutdownState::ForcingKill: + if (!any_running() || std::chrono::steady_clock::now() >= shutdown_deadline_) { + shutdown_state_ = ShutdownState::Finalizing; // give up on the rest + } + return false; + + case ShutdownState::Finalizing: { + bool reboot = shutdown_kind_ == ShutdownKind::Reboot; + log_info(kTag, "finalizing: ", reboot ? "reboot" : "poweroff"); + ::sync(); + unmount_filesystems(); + ::sync(); + if (reboot) { + log_info(kTag, "reboot()"); + if (::reboot(RB_AUTOBOOT) != 0) { + log_info(kTag, "reboot failed: ", std::strerror(errno)); + } + } else { + log_info(kTag, "power off"); + if (::reboot(RB_POWER_OFF) != 0) { + log_info(kTag, "poweroff failed: ", std::strerror(errno)); + } + } + return true; // nothing left do to; run() will _exit(0) + } + } + return false; +} + +void Supervisor::handle_sigchld() { + struct signalfd_siginfo si; + ssize_t n; + while ((n = ::read(sigfd_, &si, sizeof si)) == static_cast(sizeof si)) { + switch (si.ssi_signo) { + case SIGCHLD: + reap_children(); + break; + case SIGTERM: + case SIGINT: + // SIGTERM -> reboot, SIGINT -> poweroff (distinct paths to test). + begin_shutdown(si.ssi_signo == SIGTERM ? ShutdownKind::Reboot + : ShutdownKind::PowerOff); + break; + case SIGQUIT: + log_info(kTag, "SIGQUIT: emergency exit"); + _exit(1); + break; + case SIGHUP: + case SIGUSR1: + log_info(kTag, "reload requested (not yet implemented)"); + break; + default: + break; + } + } +} + +[[noreturn]] void Supervisor::run() { + setup_signals(); + open_console(); + + log_info(kTag, "bajia init starting (pid 1)"); + + // boot sequence: fire ordered triggers. + for (const char* ev : {"early-init", "init", "boot"}) { + for (auto& action : config_.actions) { + if (action.trigger == ev) execute_action(action); + } + } + + struct epoll_event events[8]; + for (;;) { + // while shutting down, wake up exactly when the next state transition + // is due; otherwise block indefinitely. + int timeout = shutdown_state_ == ShutdownState::Running ? -1 + : shutdown_timeout_ms(); + int n = ::epoll_wait(epfd_, events, 8, timeout); + + if (n < 0) { + if (errno == EINTR) continue; + log_info(kTag, "epoll_wait: ", std::strerror(errno)); + _exit(1); + } + + for (int i = 0; i < n; ++i) { + if (events[i].data.fd == sigfd_) { + handle_sigchld(); + } + } + + reap_children(); // also catch anything before the next event + + if (advance_shutdown()) { + log_info(kTag, "shutdown complete"); + _exit(0); + } + } +} + +} // namespace bajia diff --git a/src/supervisor.hpp b/src/supervisor.hpp new file mode 100644 index 0000000..91e2b75 --- /dev/null +++ b/src/supervisor.hpp @@ -0,0 +1,75 @@ +// supervisor.hpp - PID 1 event loop and service supervisor. +// +// owns the signalfd+epoll loop, spawns/reaps services, fires actions on +// triggers, and never returns (except on explicit termination). +#pragma once + +#include "config.hpp" + +#include +#include + +namespace bajia { + +// what to do once a shutdown request has completed. +enum class ShutdownKind { + PowerOff, + Reboot, +}; + +class Supervisor { +public: + explicit Supervisor(Config config); + ~Supervisor(); + + // fire all actions whose trigger matches `trigger`. Queued and run by + // the event loop. `trigger` is one of: early-init, init, boot, shutdown, + // or a custom event name. + void trigger(const std::string& event); + + void start_service(const std::string& name); + void stop_service(const std::string& name, bool kill = false); + void restart_service(const std::string& name); + + // run until terminated (e.g. by SIGTERM/SIGINT -> calls shutdown()). Does + // not return normally. + [[noreturn]] void run(); + +private: + // shutdown is a state machine driven from the event loop so that SIGKILL + // grace periods and child reaping keep working while we wind down. + enum class ShutdownState { + Running, // normal operation + FiringActions, // ran `on shutdown` actions, SIGTERM all services + StoppingServices, // waiting for graceful exits (bounded by a deadline) + ForcingKill, // grace elapsed; SIGKILL the stragglers + Finalizing, // unmount + sync + reboot/poweroff + }; + + Config config_; + int epfd_ = -1; + int sigfd_ = -1; + bool shutdown_requested_ = false; + ShutdownKind shutdown_kind_ = ShutdownKind::PowerOff; + ShutdownState shutdown_state_ = ShutdownState::Running; + std::chrono::steady_clock::time_point shutdown_deadline_{}; + + void setup_signals(); + void open_console(); + void spawn_service(Service& svc, bool missing_ok); + void reap_children(); + void execute_action(Action& action); + void run_exec_command(const Command& cmd); + void run_command(Command& cmd); + + void begin_shutdown(ShutdownKind kind); + // step the shutdown state machine; returns true when the loop should exit. + bool advance_shutdown(); + bool any_running() const; + int shutdown_timeout_ms() const; + void unmount_filesystems(); + + void handle_sigchld(); +}; + +} // namespace bajia diff --git a/tools/run_vm.py b/tools/run_vm.py new file mode 100644 index 0000000..03840ac --- /dev/null +++ b/tools/run_vm.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""boot bajia as PID 1 in a QEMU VM using a tiny initramfs. + +sets up everything needed for a realistic test run: + - builds bajia (optionally statically linked) + - builds an initramfs with bajia as /init, busybox, and a test init.rc + - runs qemu-system-x86_64 with a GTK display, serial console, and a gdb stub + +examples: + python3 tools/run_vm.py --busybox /path/to/busybox-static + python3 tools/run_vm.py --fetch-busybox --nographic + python3 tools/run_vm.py --kernel /boot/vmlinuz-$(uname -r) --gdb + python3 tools/run_vm.py --config my-init.rc + +inside the guest: log in as `root` (passwordless by default, or use +--root-password), then `kill -TERM 1` -> reboot path, `kill -INT 1` -> +poweroff path. +bajia is built statically by default: a dynamic binary cannot exec inside the +initramfs (no libc there). Use --no-static only if you ship the libs too. +""" +from __future__ import annotations + +import argparse +import glob +import gzip +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +BUILD = ROOT / "build" +BAJIA = BUILD / "bajia" + +DEFAULT_RC = """\ +# generated by tools/run_vm.py - minimal init.rc for VM testing. +on early-init + mount proc /proc proc + mount sysfs /sys sysfs + mount devtmpfs /dev devtmpfs + mkdir /dev/pts 0755 + mount devpts /dev/pts devpts + mkdir /run 0755 + mount tmpfs /run tmpfs + +on init + write /proc/sys/kernel/hostname bajia + log **** bajia init on-line **** + +on boot + start console-serial + start console-tty1 + +on shutdown + log shutdown: stopping services + +service console-serial /bin/getty -L ttyS0 115200 vt100 + console + respawn always + +service console-tty1 /bin/getty -L 38400 tty1 vt100 + console + respawn always +""" + +# source tarballs of busybox (github.com/mirror/busybox); a pinned tag is +# tried first, then the master branch. built statically into $XDG_CACHE_HOME. +BUSYBOX_URLS = [ + "https://github.com/mirror/busybox/archive/refs/tags/1_36_1.tar.gz", + "https://github.com/mirror/busybox/archive/refs/heads/master.tar.gz", +] + +BUSYBOX_APPLETS = ["sh", "getty", "mount", "sync", "ls", "cat", "kill", "ps", + "poweroff", "reboot", "mkdir", "mknod", "login"] + + +def run(cmd, **kw) -> subprocess.CompletedProcess: + print("$", " ".join(str(c) for c in cmd)) + return subprocess.run(cmd, **kw) + + +def build_bajia(static: bool) -> Path: + env = dict(os.environ) + if static: + print("building bajia (statically linked)...") + env["CXX"] = env.get("CXX", "g++") + " -static" + r = run(["python3", "configure.py"], env=env) + if r.returncode != 0: + sys.exit("configure.py failed") + r = run(["ninja", "-C", str(BUILD)]) + if r.returncode != 0: + sys.exit("ninja build failed") + return BAJIA + +def find_kernel() -> Path | None: + p = Path("/boot/vmlinuz-" + os.uname().release) + if p.is_file(): + return p + matches = sorted(glob.glob("/boot/vmlinuz-*")) + return Path(matches[-1]) if matches else None + +def is_static(path: Path) -> bool: + filetool = shutil.which("file") + if not filetool: + return True # cannot tell; don't nag + out = run([filetool, "-b", str(path)], capture_output=True, text=True) + blob = out.stdout.lower() + return "static" in blob or "statically" in blob + +def find_busybox() -> Path | None: + for cand in (os.environ.get("BAJIA_BUSYBOX"), shutil.which("busybox")): + if cand and Path(cand).is_file(): + return Path(cand) + return None + +def fetch_busybox(cache: Path) -> Path: + """download busybox source from github.com/mirror/busybox and build it + statically. The resulting binary is cached in `cache`.""" + dst = cache / "busybox" + if dst.is_file(): + print("using cached busybox:", dst) + return dst + + src_dir = cache / "busybox-src" + src_dir.mkdir(parents=True, exist_ok=True) + tree = None + for url in BUSYBOX_URLS: + try: + print("downloading busybox source:", url) + req = urllib.request.Request(url, headers={"User-Agent": "bajia-run-vm"}) + with urllib.request.urlopen(req, timeout=120) as resp, open( + cache / "busybox.tar.gz", "wb") as out: + shutil.copyfileobj(resp, out) + print("extracting busybox source...") + with tarfile.open(cache / "busybox.tar.gz", "r:gz") as tf: + tf.extractall(src_dir) + tops = [p for p in src_dir.iterdir() if p.is_dir()] + if len(tops) == 1: + tree = tops[0] + break + print(" unexpected source layout, trying next URL") + except Exception as e: # noqa: BLE001 - try the next URL + print(" failed:", e) + if not tree: + sys.exit("could not fetch busybox source from github.com/mirror/busybox") + + env = dict(os.environ) + r = run(["make", "defconfig"], cwd=tree, env=env) + if r.returncode != 0: + sys.exit("busybox defconfig failed") + r = run(["sed", "-i", "s|# CONFIG_STATIC is not set|CONFIG_STATIC=y|", + tree / ".config"]) + if r.returncode != 0: + sys.exit("busybox config edit failed") + # CONFIG_TC needs the CBQ uapi structs (tc_cbq_*) that modern kernel + # headers no longer provide; not needed in an initramfs, so drop it. + r = run(["sed", "-i", "s|^CONFIG_TC=y$|# CONFIG_TC is not set|", + tree / ".config"]) + if r.returncode != 0: + sys.exit("busybox config edit failed") + r = run(["make", f"-j{os.cpu_count() or 2}", "busybox"], cwd=tree, env=env) + if r.returncode != 0: + sys.exit("busybox build failed; if another applet breaks on your " + "kernel headers, disable it in busybox-src/.config and pass " + "--busybox with a prebuilt static binary instead") + + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(tree / "busybox", dst) + dst.chmod(0o755) + print("built busybox:", dst) + return dst + +def default_cache_dir() -> Path: + base = Path(os.environ.get("XDG_CACHE_HOME", + str(Path.home() / ".cache"))) + return base / "bajia" + +def crypt_password(password: str) -> str: + try: + import crypt + return crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512)) + except (ImportError, AttributeError): + p = subprocess.run(["openssl", "passwd", "-6", password], + capture_output=True, text=True) + if p.returncode != 0: + sys.exit("cannot hash the root password: need python `crypt` or openssl") + return p.stdout.strip() + +def root_passwd_line(password: str | None) -> str: + field = crypt_password(password) if password else "" + return f"root:{field}:0:0:root:/:/bin/sh\n" + +def build_initramfs(init: Path, busybox: Path, rc_text: str, root_password: str | None, + keep: bool) -> Path: + if not shutil.which("cpio"): + sys.exit("cpio not found (install cpio)") + root = Path(tempfile.mkdtemp(prefix="bajia-root-")) + try: + for sub in ("etc/bajia", "bin", "sbin", "usr/sbin", "usr/bin", + "dev", "proc", "sys", "run", "tmp"): + (root / sub).mkdir(parents=True) + + shutil.copy(init, root / "init") + (root / "init").chmod(0o755) + + shutil.copy(busybox, root / "bin" / "busybox") + (root / "bin" / "busybox").chmod(0o755) + for applet in BUSYBOX_APPLETS: + (root / "bin" / applet).symlink_to("busybox") + + (root / "etc" / "bajia" / "init.rc").write_text(rc_text) + (root / "etc" / "passwd").write_text(root_passwd_line(root_password)) + + p = run(["bash", "-c", + "cd \"$1\" && find . -print0 | cpio --null -o -H newc", + "bajia-initramfs", str(root)], stdout=subprocess.PIPE) + if p.returncode != 0: + sys.exit("cpio packing failed") + initrd = Path(tempfile.gettempdir()) / "bajia-initrd.cpio.gz" + initrd.write_bytes(gzip.compress(p.stdout)) + + if keep: + print("initramfs root tree kept at:", root) + return initrd + finally: + if not keep: + shutil.rmtree(root, ignore_errors=True) + +def qemu_command(kernel: Path, initrd: Path, args: argparse.Namespace) -> list[str]: + qemu = args.qemu or shutil.which("qemu-system-x86_64") or "qemu-system-x86_64" + display = args.display + if display is None: + display = "gtk" if os.environ.get("DISPLAY") else "none" + cmd = [ + qemu, + "-M", args.machine, + "-m", str(args.mem), + "-smp", str(args.smp), + "-kernel", str(kernel), + "-initrd", str(initrd), + "-append", + f"console=tty1 console=ttyS0 rdinit=/init loglevel={args.loglevel}", + "-display", display, + "-serial", "stdio", + ] + if args.nographic: + cmd[cmd.index("-display") + 1] = "none" + if args.gdb or args.wait_gdb: + cmd += ["-gdb", "tcp::1234", "-S"] if args.wait_gdb else ["-s"] + cmd += ["-no-reboot", "-no-shutdown"] + return cmd + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--kernel", help="kernel bzImage (default: host /boot/vmlinuz-*)") + ap.add_argument("--busybox", help="static busybox binary to bundle") + ap.add_argument("--fetch-busybox", action="store_true", + help="download busybox source (github.com/mirror/busybox), " + "build it static, cache in ~/.cache/bajia") + ap.add_argument("--config", type=Path, + help="use this init.rc instead of the bundled test config") + ap.add_argument("--root-password", default=None, + help="password for the root account in the guest " + "(default: passwordless login)") + ap.add_argument("--no-static", action="store_true", + help="build bajia dynamically linked (won't exec in the " + "initramfs unless you also pack the libs)") + ap.add_argument("--no-build", action="store_true", + help="use the existing build/bajia without rebuilding") + ap.add_argument("--display", choices=["gtk", "sdl", "none"], + help="QEMU display backend (default: gtk if $DISPLAY set)") + ap.add_argument("--nographic", action="store_true", + help="headless; serial console on stdio") + ap.add_argument("--machine", default="q35", help="QEMU machine type") + ap.add_argument("--mem", type=int, default=512, help="RAM in MB") + ap.add_argument("--smp", type=int, default=2, help="virtual CPUs") + ap.add_argument("--loglevel", type=int, default=4, + help="kernel loglevel (4=dmesg, 7=everything)") + ap.add_argument("--qemu", help="qemu binary (default: qemu-system-x86_64)") + ap.add_argument("--gdb", action="store_true", help="expose a gdb stub on :1234") + ap.add_argument("--wait-gdb", action="store_true", + help="pause the machine until a gdb client attaches") + ap.add_argument("--keep-initramfs", action="store_true", + help="don't delete the initramfs staging tree") + args = ap.parse_args() + + init = BAJIA if args.no_build else build_bajia(not args.no_static) + if not init.is_file(): + sys.exit(f"bajia not built at {init} (drop --no-build)") + if not is_static(init): + print("warning: bajia is dynamically linked; /init will fail to exec " + "inside the initramfs (error -2). Rebuild with --no-static " + "unset (static is the default) or drop --no-build.") + + kernel = Path(args.kernel) if args.kernel else find_kernel() + if not kernel or not kernel.is_file(): + sys.exit("no kernel found: pass --kernel /path/to/bzImage or install a " + "kernel and use its /boot/vmlinuz-*") + + if args.fetch_busybox: + busybox = fetch_busybox(default_cache_dir()) + else: + busybox = Path(args.busybox) if args.busybox else find_busybox() + if not busybox or not busybox.is_file(): + sys.exit("no busybox found: pass --busybox, --fetch-busybox, or install " + "busybox-static (Debian/Ubuntu: `apt install busybox-static`)") + if not is_static(busybox): + print("warning: busybox looks dynamically linked; a static build is " + "safest inside an initramfs") + + rc_text = args.config.read_text() if args.config else DEFAULT_RC + initrd = build_initramfs(init, busybox, rc_text, args.root_password, + keep=args.keep_initramfs) + print("initramfs:", initrd, f"({initrd.stat().st_size / 1024:.0f} KB)") + + cmd = qemu_command(kernel, initrd, args) + print("$", " ".join(cmd)) + return subprocess.run(cmd).returncode + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(130) \ No newline at end of file