more things

This commit is contained in:
Hedy88 2026-08-27 22:50:44 +01:00
commit 6e60709283
No known key found for this signature in database
15 changed files with 1403 additions and 294 deletions

6
.clang-format Normal file
View file

@ -0,0 +1,6 @@
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 0
SortIncludes: false
PointerAlignment: Left
ReflowComments: false

View file

@ -15,14 +15,13 @@ format.
roadmap:
- user/group privilege drop (`user`, `group`, supplementary groups)
- `SIGCHLD` crash-window limiting (rate-limited restarts; `crash-threshold`/
`crash-window` are parsed but not yet enforced by the reaper)
- dependency ordering between services
- `SIGCHLD` crash-window limiting (rate-limited restarts)
- property triggers (`property:<k>=<v>`) and `setprop`/`getprop`
- per-service logging to files
- `reboot`/`poweroff` path with ordered unmount
- `SIGHUP` config reload
- SELinux
- readiness/socket activation
## building
@ -62,17 +61,18 @@ See [`etc/init.rc`](etc/init.rc) for a complete example.
```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
user = root|other # uid after the privilege drop (name or number)
group = GROUP [GROUP...] # primary gid + supplementary groups (names/numbers)
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
seclabel = CONTEXT # SELinux exec context (--selinux build)
setenv = K=V # extra environment (repeatable)
cwd = /path
```
### actions
@ -91,6 +91,62 @@ on TRIGGER
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.
boot triggers fire in order: `early-init`, `init`, `boot`. `shutdown` triggers
fire when the system is winding down. property/`service-*` triggers are on the
roadmap.
Services run as `root` by default; `user`/`group` trigger a full privilege
drop (supplementary groups, then gid, then uid) before exec.
## control
A running init listens on an abstract unix socket (`@bajia`). The bundled
`bctl` client drives it:
```sh
bctl status # list services + state
bctl start NAME # start a service
bctl stop NAME # graceful stop (SIGTERM)
bctl restart NAME # restart a service
bctl trigger EVENT # fire an action trigger
bctl reload # re-parse init.rc and reconcile services
bctl shutdown [poweroff|reboot]
```
`reload` (also `kill -HUP 1`) re-parses the rc files: removed services are
stopped, added services registered, and running services whose definition
changed are restarted with the new definition. A parse error rejects the
reload and keeps the live config.
## SELinux (experimental)
SELinux support is opt-in (`configure.py --selinux`, adds `-DBAJIA_SELINUX`
+ `-lselinux`). With it enabled, PID 1 mounts selinuxfs, loads the policy
from `/etc/selinux/config`, calls `selinux_restorecon` on the core tree, and
applies a per-service exec label via the `seclabel = CONTEXT` service option.
To bring up a policy without hand-writing one, reuse the host's installed
policy (Fedora/SELinux hosts have one at `/etc/selinux/<type>/`) in
permissive mode:
```sh
python3 tools/run_vm.py --selinux
```
This bundles the host `policy.policy.<vers>` and `file_contexts` into the
initramfs, writes `SELINUX=permissive`, and boots `selinux=1 enforcing=0`.
Watch the serial console for `selinux: policy loaded, enforcing=0`; a
`selinux-probe` service prints the runtime exec contexts:
```
probe-ctx=system_u:system_r:init_t:s0 init-ctx=system_u:system_r:kernel_t:s0
```
The guest kernel must support SELinux and the policy version must match the
kernel's (`cat /sys/fs/selinux/policyvers`). Once permissive is stable,
read `avc: denied` lines from dmesg and iterate toward a minimal custom
policy with `checkpolicy`/`audit2allow`, then flip to `enforcing=1`.
Limitations: uses the dynamic libselinux (no static build on Fedora), so the
`--selinux` init is dynamically linked and the loader + libs (`libselinux`,
`libpcre2-8`, glibc) are bundled into the initramfs.

View file

@ -5,9 +5,13 @@ usage:
python3 configure.py
python3 configure.py --asan # enable address/undefined sanitizers
python3 configure.py --debug # -O0 instead of -O2
python3 configure.py --selinux # enable SELinux integration
python3 configure.py --compile-commands # also write build/compile_commands.json
"""
import argparse
import json
import os
import shlex
import shutil
import sys
from pathlib import Path
@ -54,10 +58,19 @@ format:
)
def emit_ninja(cxx, cxxflags, dst):
def emit_ninja(cxx, cxxflags, dst, write_cc=False):
sources = discover_sources()
objs = ["obj/" + Path(s).stem + ".o" for s in sources]
target = "bajia"
ctl_target = "bctl"
# bctl_main.cpp is a second shipped binary (the control client),
# excluded from the init objects by the *_main.cpp convention.
ctl_source = SRC / "bctl_main.cpp"
ctl_obj = "obj/bctl_main.o"
# (source, output) pairs for compile_commands.json.
cc_entries = [(SRC / s, o) for s, o in zip(sources, objs)]
cc_entries.append((ctl_source, ctl_obj))
rule_cxx = (
"rule cxx\n"
@ -77,22 +90,49 @@ def emit_ninja(cxx, cxxflags, dst):
lines.append(rule_cxx)
lines.append(rule_link)
lines.append('build {target}: link {objs}'.format(target=target, objs=" ".join(objs)))
lines.append('build {ctl}: link {ctl_obj}'.format(ctl=ctl_target, ctl_obj=ctl_obj))
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('build {ctl_obj}: cxx {ctl_src}'.format(ctl_obj=ctl_obj, ctl_src=ctl_source))
lines.append("")
lines.append('build all: phony {target}'.format(target=target))
lines.append(
'build all: phony {target} {ctl}'.format(target=target, ctl=ctl_target))
lines.append("default all")
lines.append("")
if write_cc:
write_compile_commands(cxx, cxxflags, cc_entries)
(BUILD / "obj").mkdir(exist_ok=True)
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_compile_commands(cxx, cxxflags, src_out_pairs):
"""Write build/compile_commands.json for clangd / clang-tidy."""
entries = []
for src, out in src_out_pairs:
args = [cxx] + list(cxxflags) + ["-c", str(src), "-o", out]
entries.append({
"directory": str(BUILD.resolve()),
"file": str(src.resolve()),
"output": out,
"arguments": args,
"command": shlex.join(args),
})
(BUILD / "compile_commands.json").write_text(
json.dumps(entries, indent=1) + "\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")
p.add_argument("--selinux", action="store_true",
help="enable SELinux integration (needs libselinux-dev; "
"see tools/run_vm.py --selinux for a VM test path)")
p.add_argument("--compile-commands", action="store_true",
help="write build/compile_commands.json (for clangd)")
return p.parse_args()
def main():
@ -106,6 +146,20 @@ def main():
print("error: ninja not found in PATH", file=sys.stderr)
return 1
# Wipe stale objects when compiler flags change: ninja doesn't track
# cflags, so a re-run with a different --selinux/--asan/--debug profile
# would otherwise link a half-stale tree.
stamp = {"selinux": args.selinux, "asan": args.asan, "debug": args.debug}
stamp_file = BUILD / ".configure.json"
if stamp_file.exists():
try:
prev = json.loads(stamp_file.read_text())
except (json.JSONDecodeError, OSError):
prev = None
if prev != stamp:
print("build configuration changed; cleaning build/")
shutil.rmtree(BUILD, ignore_errors=True)
BUILD.mkdir(exist_ok=True)
cxxflags = list(CXXFLAGS)
@ -117,14 +171,20 @@ def main():
if args.asan:
cxxflags += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"]
linkflags += ["-fsanitize=address,undefined"]
if args.selinux:
cxxflags += ["-DBAJIA_SELINUX"]
linkflags += ["-lselinux"]
cxxflags = [f for f in cxxflags if f not in WARNINGS]
cxxflags = WARNINGS + cxxflags
emit_ninja(CXX, cxxflags, BUILD / "build.ninja")
emit_ninja(CXX, cxxflags, BUILD / "build.ninja", write_cc=args.compile_commands)
write_makefile_convenience()
stamp_file.write_text(json.dumps(stamp, indent=1) + "\n", encoding="utf-8")
print(f"configured {len(discover_sources())} sources -> build/build.ninja")
if args.compile_commands:
print("wrote build/compile_commands.json")
print("run: ninja -C build")
return 0

View file

@ -2,7 +2,9 @@
#
# constructs:
# service NAME /path/to/exe [args...]
# user|group|oneshot|disabled|console|class|respawn|crash-*|setenv|cwd
# user = ... | group = ... | class = ... | respawn = ... | crash-threshold = ...
# crash-window = ... | setenv = ... | cwd = ... | seclabel = ...
# oneshot | disabled | console (flag options, no value)
#
# on TRIGGER
# start NAME | stop NAME | restart NAME
@ -39,16 +41,16 @@ on boot
# the console service: bind stdio to /dev/console, always respawn.
service console /sbin/getty -L ttyS0 115200 vt100
class core
class = core
console
user root
user = root
# a long-lived example daemon. respawning is the default (always).
service watchdog /usr/sbin/watchdog
class core
respawn always
class = core
respawn = always
# a one-shot job: runs once, exits, never respawns.
service boot-logo /usr/bin/show-boot-logo
class late
class = late
oneshot

93
src/bctl_main.cpp Normal file
View file

@ -0,0 +1,93 @@
// bctl.cpp - tiny line-based client for the bajia control socket (@bajia).
//
// talks to a running bajia (PID 1) to start/stop/restart services, fire
// triggers, query service status, and request shutdown
//
// wired to the abstract unix socket "@bajia" served by
// Supervisor::open_control_socket().
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <string>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
namespace {
constexpr const char kCtlName[] = "bajia";
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg)
void usage(const char* argv0) {
std::fprintf(
stderr,
"usage: %s <command> [args...]\n"
" start NAMES start a service\n"
" stop NAME gracefully stop a service (SIGTERM)\n"
" restart NAME restart a service\n"
" trigger EVENT fire a trigger (e.g. boot, shutdown)\n"
" reload re-parse the rc files and reconcile services\n"
" status list services and their state\n"
" shutdown [kind] shut down (kind: poweroff|reboot)\n"
" ping sanity check that init is alive\n",
argv0);
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
usage(argv[0]);
return 2;
}
std::string line;
for (int i = 1; i < argc; ++i) {
if (i > 1)
line += ' ';
line += argv[i];
}
line += '\n';
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
std::perror("bctl: socket");
return 1;
}
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::memcpy(addr.sun_path + 1, kCtlName,
sizeof kCtlName); // leading NUL = abstract
const socklen_t len = static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) +
1 + sizeof kCtlName);
if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), len) != 0) {
std::fprintf(stderr, "bctl: cannot connect to @%s: %s\n", kCtlName,
std::strerror(errno));
::close(fd);
return 1;
}
const ssize_t nw = ::write(fd, line.data(), line.size());
if (nw != static_cast<ssize_t>(line.size())) {
std::perror("bctl: write");
::close(fd);
return 1;
}
::shutdown(fd, SHUT_WR); // tell init no more commands; read the reply
std::string out;
char buf[512];
ssize_t n;
while ((n = ::read(fd, buf, sizeof buf)) > 0) {
out.append(buf, static_cast<size_t>(n));
}
::close(fd);
std::fwrite(out.data(), 1, out.size(), stdout);
// a line starting with "ERR" means failure (status lines end with OK).
const bool failed =
out.find("\nERR ") != std::string::npos || out.rfind("ERR ", 0) == 0;
return failed ? 1 : 0;
}

View file

@ -12,7 +12,7 @@ namespace {
std::vector<std::string> tokenize(const std::string& line) {
std::vector<std::string> out;
std::string cur;
bool in_q = false;
bool need_quote_close = false;
@ -48,7 +48,8 @@ std::vector<std::string> tokenize(const std::string& line) {
}
if (in_q) {
// unterminated quote: best effort, keep what we have.
if (!cur.empty()) out.push_back(cur);
if (!cur.empty())
out.push_back(cur);
} else if (!cur.empty()) {
out.push_back(cur);
}
@ -57,8 +58,10 @@ std::vector<std::string> tokenize(const std::string& line) {
}
RespawnPolicy parse_respawn(const std::string& s) {
if (s == "always") return RespawnPolicy::Always;
if (s == "on-failure") return RespawnPolicy::OnFailure;
if (s == "always")
return RespawnPolicy::Always;
if (s == "on-failure")
return RespawnPolicy::OnFailure;
return RespawnPolicy::Never;
}
@ -66,14 +69,16 @@ RespawnPolicy parse_respawn(const std::string& s) {
Service* Config::find_service(const std::string& name) {
for (auto& s : services) {
if (s.name == name) return &s;
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;
if (s.name == name)
return &s;
}
return nullptr;
}
@ -81,10 +86,11 @@ const Service* Config::find_service(const std::string& name) const {
// public entry point
Config parse_config(const std::vector<std::string>& files) {
Config cfg;
cfg.sources = files;
int line = 0;
std::string section_kind; // "service" or "action"
Service* cur_svc = nullptr; // service being configured
Action* cur_act = nullptr; // action being configured
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);
@ -100,7 +106,8 @@ Config parse_config(const std::vector<std::string>& files) {
while (std::getline(in, raw)) {
++line;
auto toks = tokenize(raw);
if (toks.empty()) continue;
if (toks.empty())
continue;
std::string first = toks[0];
size_t indent = raw.find_first_not_of(" \t");
@ -133,42 +140,75 @@ Config parse_config(const std::vector<std::string>& files) {
}
// service options (must already be inside a service section).
// value-taking keywords require `keyword = value` syntax.
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]);
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 == "user" || first == "group" || first == "class" ||
first == "respawn" || first == "crash-threshold" ||
first == "crash-window" || first == "setenv" ||
first == "cwd" || first == "seclabel") {
if (toks.size() < 3 || toks[1] != "=") {
throw std::runtime_error(file + ":" + std::to_string(line) +
": option '" + first +
"' requires '= value' syntax");
}
if (first == "user")
cur_svc->uid = toks[2];
else if (first == "group") {
cur_svc->gid = toks[2];
for (size_t i = 3; i < toks.size(); ++i)
cur_svc->groups.push_back(toks[i]);
} else if (first == "class")
cur_svc->service_class = toks[2];
else if (first == "respawn")
cur_svc->respawn = parse_respawn(toks[2]);
else if (first == "crash-threshold")
cur_svc->crash_threshold = std::stoi(toks[2]);
else if (first == "crash-window")
cur_svc->crash_window_secs = std::stoi(toks[2]);
else if (first == "setenv")
cur_svc->env.push_back(toks[2]);
else if (first == "cwd")
cur_svc->cwd = toks[2];
else if (first == "seclabel")
cur_svc->seclabel = toks[2];
}
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];
// unknown option keys are ignored.
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;
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 + "'");

View file

@ -4,12 +4,13 @@
// Two top-level constructs:
//
// service NAME /path/to/exec args...
// user root
// group root
// user = root # uid after the privilege drop
// group = root [g...] # primary gid + supplementary groups
// oneshot
// disabled
// class main
// respawn never|on-failure|always
// class = main
// respawn = never|on-failure|always
// seclabel = CONTEXT
// console
//
// on TRIGGER
@ -30,6 +31,11 @@
namespace bajia {
// --------------------------------------------------------------------------
// version
// --------------------------------------------------------------------------
constexpr const char* kBajiaVersion = "0.1";
// --------------------------------------------------------------------------
// Service
// --------------------------------------------------------------------------
@ -41,19 +47,24 @@ enum class RespawnPolicy {
struct Service {
std::string name;
std::vector<std::string> args; // executable path + arguments
std::vector<std::string> args; // executable path + arguments
std::string cwd = "/";
std::string uid = "root"; // resolved in supervisor
std::string uid = "root"; // resolved in supervisor
std::string gid = "root";
std::vector<std::string> groups;
bool oneshot = false; // run once, don't keep alive
bool disabled = false; // not started automatically
bool console = false; // bind stdio to the console
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<std::string> env; // "K=V" pairs
int crash_threshold = 4; // max restarts within window
int crash_window_secs = 30; // before giving up
std::vector<std::string> env; // "K=V" pairs
std::string seclabel; // SELinux exec context (optional)
// Transient runtime flag: this service was stopped by a config reload
// because its definition changed; respawn it once with the new definition.
bool restart_on_reap = false;
// Runtime state
int pid = 0;
@ -69,7 +80,7 @@ struct Command {
Start,
Stop,
Restart,
Exec, // run a synchronous command to completion
Exec, // run a synchronous command to completion
Mkdir,
Chmod,
Chown,
@ -80,11 +91,11 @@ struct Command {
Log,
};
Kind kind;
std::vector<std::string> args; // command-specific arguments
std::vector<std::string> args; // command-specific arguments
};
struct Action {
std::string trigger; // e.g. "boot", "early-init"
std::string trigger; // e.g. "boot", "early-init"
std::vector<Command> commands;
};
@ -95,9 +106,11 @@ struct Config {
std::vector<Service> services;
std::vector<Action> actions;
std::string hostname;
// the .rc files this config was parsed from (used for SIGHUP reload).
std::vector<std::string> sources;
Service* find_service(const std::string& name);
const Service* find_service(const std::string& name) const;
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

View file

@ -6,6 +6,7 @@
#include <ctime>
#include <fcntl.h>
#include <mutex>
#include <sys/stat.h>
#include <unistd.h>
namespace bajia {
@ -14,31 +15,75 @@ namespace {
std::mutex g_lock;
int g_fd = -1; // console fd (>=0 when open)
int g_scr_fd =
-1; // mirrored VT fd (/dev/tty1); systemd-style boot text on the screen
std::array<std::string, 64> g_ring;
size_t g_ring_pos = 0;
size_t g_ring_count = 0;
LogLevel g_min_level = LogLevel::Info;
// /dev/console follows the kernel's *preferred* console; with
// `console=tty1 console=ttyS0` that is ttyS0, so the VGA screen would stay
// empty. Mirror to /dev/tty1 (the screen) whenever it is a different device.
void open_screen() {
if (g_scr_fd >= 0)
return;
int fd = ::open("/dev/tty1", O_WRONLY | O_NOCTTY | O_CLOEXEC);
if (fd < 0)
return; // retried lazily on the next write (devtmpfs may not
// be mounted yet during the very first boot actions)
if (g_fd >= 0) {
struct stat a{}, b{};
if (::fstat(g_fd, &a) == 0 && ::fstat(fd, &b) == 0 &&
a.st_rdev == b.st_rdev) {
::close(fd);
return; // /dev/console is already the VT
}
}
g_scr_fd = fd;
}
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";
case LogLevel::Debug:
return "DBG";
case LogLevel::Info:
return "INF";
case LogLevel::Warn:
return "WRN";
case LogLevel::Err:
return "ERR";
}
return "???";
}
void raw_write(int fd, const std::string& s) {
size_t off = 0;
while (off < s.size()) {
ssize_t n = ::write(fd, s.data() + off, s.size() - off);
if (n < 0)
break;
off += static_cast<size_t>(n);
}
}
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<size_t>(n);
}
raw_write(g_fd, s);
} else {
::write(STDERR_FILENO, s.data(), s.size());
raw_write(STDERR_FILENO, s);
}
}
// status banners (`[ OK ]` / `[FAILED]` / welcome) go to /dev/console and
// are mirrored to the VT screen so a VM with a display shows boot progress;
// the timestamped [INF]/[WRN] log stream (`write_all`) stays console-only.
void write_status(const std::string& s) {
write_all(s);
if (g_fd >= 0) {
open_screen();
if (g_scr_fd >= 0)
raw_write(g_scr_fd, s);
}
}
@ -71,9 +116,10 @@ void log_init(const std::string& console_path, LogLevel min_level) {
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";
if (level < g_min_level)
return;
std::string line = "[" + timestamp() + "] [" + level_name(level) + "] " +
tag + ": " + msg + "\n";
std::lock_guard<std::mutex> lk(g_lock);
if (g_fd < 0 && g_ring_count < g_ring.size()) {
g_ring[g_ring_pos] = line;
@ -89,4 +135,23 @@ void log_msg(LogLevel level, const std::string& tag, const std::string& msg) {
write_all(line);
}
void log_status(LogStatus st, const std::string& msg) {
std::string line;
if (st == LogStatus::Banner) {
line = msg + "\n";
} else {
bool tty = g_fd >= 0 && ::isatty(g_fd);
if (st == LogStatus::Ok) {
line = "[" + std::string(tty ? "\x1b[32m" : "") + " OK " +
(tty ? "\x1b[0m" : "") + "] ";
} else {
line = "[" + std::string(tty ? "\x1b[31m" : "") + "FAILED" +
(tty ? "\x1b[0m" : "") + "] ";
}
line += msg + "\n";
}
std::lock_guard<std::mutex> lk(g_lock);
write_status(line);
}
} // namespace bajia

View file

@ -10,12 +10,23 @@
namespace bajia {
enum class LogLevel { Debug = 0, Info = 1, Warn = 2, Err = 3 };
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);
// high-visibility console status banners in the systemd `[ OK ]` style.
// console-only (not kmsg/ring-buffer), colored when the console is a tty.
enum class LogStatus { Banner,
Ok,
Failed };
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);
void log_status(LogStatus status, const std::string& msg);
template <typename... Args>
void log_info(const std::string& tag, Args&&... args) {

View file

@ -5,6 +5,7 @@
// `init=/path/to/bajia`.
#include "config.hpp"
#include "logger.hpp"
#include "selinux_setup.hpp"
#include "supervisor.hpp"
#include <cstdio>
@ -37,26 +38,28 @@ int main(int argc, char** argv) {
// 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) {
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 "
"bajia: refusing to run as pid %d (not PID 1). "
"launch it via the kernel (init=...), or pass --run-as-user "
"for a development run.\n",
::getpid());
return 1;
@ -64,9 +67,11 @@ int main(int argc, char** argv) {
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 (::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");
::fprintf(stderr,
"bajia: no config given and /etc/bajia/init.rc not found.\n");
return 1;
}
}
@ -79,12 +84,19 @@ int main(int argc, char** argv) {
return 1;
}
// logger targets the console; falls back to stderr until the console is ready.
// logger targets the console; falls back to stderr until the console is
// ready.
log_init("/dev/console", LogLevel::Info);
log_status(LogStatus::Banner,
std::string("Welcome to bajia ") + kBajiaVersion);
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");
if (::getpid() == 1) {
selinux_setup();
}
Supervisor supervisor(std::move(config));
supervisor.run(); // [[noreturn]]
}

88
src/selinux_setup.cpp Normal file
View file

@ -0,0 +1,88 @@
// selinux_setup.cpp - setup SELinux policies (only when built with
// `configure.py --selinux`; otherwise this compiles to an empty TU).
#include "selinux_setup.hpp"
#ifdef BAJIA_SELINUX
#include "logger.hpp"
#include <cerrno>
#include <cstdarg>
#include <cstring>
#include <selinux/restorecon.h>
#include <selinux/selinux.h>
#include <string>
#include <sys/mount.h>
#include <sys/stat.h>
#include <unistd.h>
namespace bajia {
int selinux_log_callback(int type, const char* fmt, ...) {
(void)type;
va_list ap;
va_start(ap, fmt);
va_list copy;
va_copy(copy, ap);
int len = vsnprintf(nullptr, 0, fmt, copy);
va_end(copy);
if (len >= 0) {
std::string msg(static_cast<size_t>(len), '\0');
vsnprintf(msg.data(), msg.size() + 1, fmt, ap);
log_info("selinux", msg.c_str());
}
va_end(ap);
return 0;
}
int selinux_audit_callback(void* auditdata, security_class_t cls, char* msg,
size_t msglen) {
(void)auditdata;
(void)msglen;
log_info("selinux-audit", "class: ", std::to_string(cls), " msg: ", msg);
return 0;
}
void selinux_setup() {
selinux_callback cb{};
cb.func_log = selinux_log_callback;
selinux_set_callback(SELINUX_CB_LOG, cb);
cb.func_audit = selinux_audit_callback;
selinux_set_callback(SELINUX_CB_AUDIT, cb);
// make sure /sys is mounted, then let selinux_init_load_policy() do the
// real work: it mounts selinuxfs, parses /etc/selinux/config + the
// enforcing= cmdline flag and loads the policy. (libselinux's own
// is_selinux_enabled() needs a prior config parse, so we don't gate on it
// here; its return value is authoritative.)
::mkdir("/sys", 0755);
if (::mount("sysfs", "/sys", "sysfs", 0, nullptr) != 0 && errno != EBUSY) {
log_info("selinux", "cannot mount sysfs: ", std::strerror(errno));
return;
}
int enforce = -1;
if (selinux_init_load_policy(&enforce) != 0) {
log_info("selinux",
"policy load failed (no selinuxfs, kernel disabled "
"via selinux=0, and/or no policy in /etc/selinux/<type>/policy)");
return;
}
log_info("selinux",
"policy loaded, enforcing=", std::to_string(enforce == 1));
// relabel the core tree so every path matches the policy's file_contexts.
for (const char* path : {"/dev", "/run", "/etc", "/bin", "/sbin", "/usr"}) {
if (selinux_restorecon(path, SELINUX_RESTORECON_RECURSE) != 0) {
log_info("selinux", "restorecon ", path, ": ", std::strerror(errno));
}
}
log_info("selinux", "setup complete");
}
} // namespace bajia
#endif // BAJIA_SELINUX

29
src/selinux_setup.hpp Normal file
View file

@ -0,0 +1,29 @@
#pragma once
// SELinux integration is optional: it pulls in libselinux, which is only
// useful on a real Android-like system with policies. Compile with
// `configure.py --selinux` to enable; otherwise these are no-ops so the
// embedded/VM builds stay self-contained.
#ifdef BAJIA_SELINUX
#include <selinux/selinux.h>
namespace bajia {
int selinux_log_callback(int type, const char* fmt, ...);
int selinux_audit_callback(void* auditdata, security_class_t cls, char* msg,
size_t msglen);
void selinux_setup();
} // namespace bajia
#else // !BAJIA_SELINUX
namespace bajia {
inline void selinux_setup() {}
} // namespace bajia
#endif // BAJIA_SELINUX

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@
#include <chrono>
#include <string>
#include <unordered_map>
namespace bajia {
@ -18,7 +19,7 @@ enum class ShutdownKind {
};
class Supervisor {
public:
public:
explicit Supervisor(Config config);
~Supervisor();
@ -35,7 +36,7 @@ public:
// not return normally.
[[noreturn]] void run();
private:
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 {
@ -49,6 +50,8 @@ private:
Config config_;
int epfd_ = -1;
int sigfd_ = -1;
int ctl_fd_ = -1;
std::unordered_map<int, std::string> ctl_clients_;
bool shutdown_requested_ = false;
ShutdownKind shutdown_kind_ = ShutdownKind::PowerOff;
ShutdownState shutdown_state_ = ShutdownState::Running;
@ -60,7 +63,18 @@ private:
void reap_children();
void execute_action(Action& action);
void run_exec_command(const Command& cmd);
void run_command(Command& cmd);
bool run_command(Command& cmd);
// re-parse the .rc files and reconcile live services: removed services are
// stopped, added ones registered, changed ones restarted. Called from
// SIGHUP (and the `reload` control command).
void reload_config();
// control socket ("@bajia") so userland can drive init: start/stop/restart
// services, fire triggers, query status, request shutdown.
void open_control_socket();
void ctl_accept();
void ctl_handle_client(int fd);
std::string ctl_execute(const std::string& line);
void begin_shutdown(ShutdownKind kind);
// step the shutdown state machine; returns true when the loop should exit.

View file

@ -11,12 +11,19 @@ examples:
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
python3 tools/run_vm.py --selinux # boot with the host SELinux policy (permissive)
inside the guest: log in as `root` (passwordless by default, or use
--root-password), then `kill -TERM 1` -> reboot path, `kill -INT 1` ->
--root-password). `bctl status` / `bctl shutdown poweroff` drive the
init over its control socket; `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.
--selinux flips bajia to a dynamic build (there is no static libselinux on
Fedora), bundles libselinux/libpcre2/glibc + the loader into the initramfs,
and copies the host's /etc/selinux/<type> policy (loaded permissively). This
is the first stage of bootstrapping a real policy: boot, read the `avc:
denied` lines, refine, then flip to enforcing.
"""
from __future__ import annotations
@ -60,11 +67,11 @@ on shutdown
service console-serial /bin/getty -L ttyS0 115200 vt100
console
respawn always
respawn = always
service console-tty1 /bin/getty -L 38400 tty1 vt100
console
respawn always
respawn = always
"""
# source tarballs of busybox (github.com/mirror/busybox); a pinned tag is
@ -77,18 +84,19 @@ BUSYBOX_URLS = [
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:
def build_bajia(static: bool, selinux: bool = False) -> Path:
env = dict(os.environ)
cfg = ["python3", "configure.py"]
if selinux:
cfg.append("--selinux")
if static:
print("building bajia (statically linked)...")
env["CXX"] = env.get("CXX", "g++") + " -static"
r = run(["python3", "configure.py"], env=env)
r = run(cfg, env=env)
if r.returncode != 0:
sys.exit("configure.py failed")
r = run(["ninja", "-C", str(BUILD)])
@ -96,6 +104,64 @@ def build_bajia(static: bool) -> Path:
sys.exit("ninja build failed")
return BAJIA
SELINUX_CONFIG = Path("/etc/selinux/config")
def host_selinux_type() -> str:
if not SELINUX_CONFIG.is_file():
return "targeted"
for line in SELINUX_CONFIG.read_text().splitlines():
line = line.strip()
if line.startswith("SELINUXTYPE="):
return line.split("=", 1)[1].strip().strip('"')
return "targeted"
def selinux_policy_files() -> list[tuple[Path, str]]:
"""Return (host_path, initramfs_relative_path) pairs for the policy payload."""
typ = host_selinux_type()
base = Path("/etc/selinux") / typ
pols = sorted((base / "policy").glob("policy.*"))
if not pols:
sys.exit(f"--selinux: no policy under {base / 'policy'}/ "
"(install selinux-policy-targeted, a Fedora SELinux host is assumed)")
# libselinux 3.x looks for the restorecon table at contexts/files/file_contexts
# (older versions used contexts/file_contexts); bundle whichever exists.
fc = next((p for p in (base / "contexts" / "files" / "file_contexts",
base / "contexts" / "file_contexts") if p.is_file()), None)
if not fc:
sys.exit(f"--selinux: missing file_contexts under {base / 'contexts'}/")
fc_rel = "etc/selinux/" + typ + "/" + str(fc.relative_to(base))
prefix = f"etc/selinux/{typ}/"
return [(pols[-1], prefix + f"policy/{pols[-1].name}"),
(fc, fc_rel)]
def bundle_dynamic_libs(init: Path, root: Path) -> None:
"""Copy the dynamic loader + resolved .so deps into the initramfs,
mirroring their absolute paths so the interpreter finds them."""
out = run(["ldd", str(init)], capture_output=True, text=True)
if out.returncode != 0:
sys.exit("ldd failed on " + str(init))
libs: list[str] = []
for line in out.stdout.splitlines():
line = line.strip()
if "=>" in line:
path = line.split("=>", 1)[1].strip().split(" ", 1)[0].strip()
else: # "linux-vdso" or the loader line "/lib64/ld-linux-x86-64.so.2 (0x...)"
path = line.split(" ", 1)[0].strip()
if path.startswith("/") and Path(path).is_file():
libs.append(path)
seen: set[str] = set()
for lib in libs: # preserve first-seen order
if lib in seen:
continue
seen.add(lib)
dst = root / lib.lstrip("/")
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(lib, dst)
dst.chmod(0o755)
print("bundled dynamic libs:", ", ".join(seen))
if not seen:
sys.exit("ldd reported no libraries - unexpected for a dynamic binary")
def find_kernel() -> Path | None:
p = Path("/boot/vmlinuz-" + os.uname().release)
if p.is_file():
@ -194,8 +260,20 @@ 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"
# appended to the test init.rc; started on boot, prints the exec context of a
# seclabel'd service and of init itself, then exits.
SELINUX_RC_PROBE = """
service selinux-probe /bin/sh -c "echo probe-ctx=$(cat /proc/self/attr/current) init-ctx=$(cat /proc/1/attr/current)"
console
seclabel = system_u:system_r:init_t:s0
respawn = never
on boot
start selinux-probe
"""
def build_initramfs(init: Path, busybox: Path, rc_text: str, root_password: str | None,
keep: bool) -> Path:
selinux: bool, keep: bool) -> Path:
if not shutil.which("cpio"):
sys.exit("cpio not found (install cpio)")
root = Path(tempfile.mkdtemp(prefix="bajia-root-"))
@ -204,19 +282,52 @@ def build_initramfs(init: Path, busybox: Path, rc_text: str, root_password: str
"dev", "proc", "sys", "run", "tmp"):
(root / sub).mkdir(parents=True)
if selinux:
for src, rel in selinux_policy_files():
dst = root / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(src, dst)
selcfg = root / "etc" / "selinux" / "config"
selcfg.write_text(f"SELINUX=permissive\nSELINUXTYPE={host_selinux_type()}\n")
bundle_dynamic_libs(init, root)
elif 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.")
if selinux:
rc_text = rc_text + SELINUX_RC_PROBE
shutil.copy(init, root / "init")
(root / "init").chmod(0o755)
ctl = BUILD / "bctl"
if ctl.is_file():
shutil.copy(ctl, root / "bin" / "bctl")
(root / "bin" / "bctl").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))
(root / "etc" / "passwd").write_text(
root_passwd_line(root_password) +
"nobody:x:65534:65534:nobody:/:/bin/sh\n")
(root / "etc" / "group").write_text(
"root:x:0:\n"
"nobody:x:65534:\n"
"daemon:x:1:\n")
# The staging tree is owned by the host user and mkdtemp makes the
# top dir 0700; GNU cpio preserves both, so without this the guest's
# "/" would be mode 0700 owned by uid 1000 -- fine for root services,
# but dropped-privilege services couldn't traverse it. Stamp owner
# root:root (no host chown needed) and make the root traversable.
p = run(["bash", "-c",
"cd \"$1\" && find . -print0 | cpio --null -o -H newc",
"cd \"$1\" && chmod 0755 . && "
"find . -print0 | cpio --null -o -H newc --owner=0:0",
"bajia-initramfs", str(root)], stdout=subprocess.PIPE)
if p.returncode != 0:
sys.exit("cpio packing failed")
@ -235,6 +346,9 @@ def qemu_command(kernel: Path, initrd: Path, args: argparse.Namespace) -> list[s
display = args.display
if display is None:
display = "gtk" if os.environ.get("DISPLAY") else "none"
append = (f"console=tty1 console=ttyS0 rdinit=/init loglevel={args.loglevel}")
if args.selinux:
append += " selinux=1 enforcing=0"
cmd = [
qemu,
"-M", args.machine,
@ -242,13 +356,15 @@ def qemu_command(kernel: Path, initrd: Path, args: argparse.Namespace) -> list[s
"-smp", str(args.smp),
"-kernel", str(kernel),
"-initrd", str(initrd),
"-append",
f"console=tty1 console=ttyS0 rdinit=/init loglevel={args.loglevel}",
"-append", append,
"-display", display,
"-serial", "stdio",
]
if args.nographic:
cmd[cmd.index("-display") + 1] = "none"
if args.serial_log:
cmd[cmd.index("-display") + 1] = "none"
cmd[cmd.index("-serial") + 1] = f"file:{args.serial_log}"
if args.gdb or args.wait_gdb:
cmd += ["-gdb", "tcp::1234", "-S"] if args.wait_gdb else ["-s"]
cmd += ["-no-reboot", "-no-shutdown"]
@ -287,12 +403,17 @@ def main() -> int:
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")
ap.add_argument("--selinux", action="store_true",
help="bundle the host SELinux policy + libs, boot permissive")
ap.add_argument("--serial-log", type=Path,
help="write the serial console to this file (forces -display none)")
args = ap.parse_args()
init = BAJIA if args.no_build else build_bajia(not args.no_static)
static = not args.no_static and not args.selinux
init = BAJIA if args.no_build else build_bajia(static, args.selinux)
if not init.is_file():
sys.exit(f"bajia not built at {init} (drop --no-build)")
if not is_static(init):
if not static and not args.selinux and 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.")
@ -315,7 +436,7 @@ def main() -> int:
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)
selinux=args.selinux, keep=args.keep_initramfs)
print("initramfs:", initrd, f"({initrd.stat().st_size / 1024:.0f} KB)")
cmd = qemu_command(kernel, initrd, args)