From 3a9d5e47d4aa01bc404c349ccd9cf28c75db7ce9 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Tue, 13 Oct 2026 02:10:11 +0800 Subject: [PATCH] feat(sandbox): prepare protected single-node factory delegation --- backend/tests/test_delivery.py | 26 +++ .../crates/sandbox/src/factory_preparation.rs | 205 ++++++++++++++++++ judge/crates/sandbox/src/lib.rs | 2 + judge/crates/sandbox/src/main.rs | 20 ++ scripts/release/README.md | 14 ++ scripts/release/factory_service.py | 115 ++++++++++ 6 files changed, 382 insertions(+) create mode 100644 judge/crates/sandbox/src/factory_preparation.rs create mode 100644 scripts/release/factory_service.py diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index 9af40d5..161bc42 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -19,6 +19,7 @@ proxy = importlib.import_module("scripts.release.proxy") services = importlib.import_module("scripts.release.services") service_bootstrap = importlib.import_module("scripts.release.service_bootstrap") service_install = importlib.import_module("scripts.release.service_install") +factory_service = importlib.import_module("scripts.release.factory_service") def test_delivery_authorities_and_layout(): @@ -492,3 +493,28 @@ def test_unit_snapshot_binding_and_inventory_cannot_add_a_privileged_unit(): def test_unit_snapshot_does_not_adopt_foreign_or_ambiguous_targets(target): with pytest.raises(policy.DeliveryError, match="unknown system unit target"): service_install.previous_batch(target, "kf-api.service") + + +def test_factory_composition_keeps_privileged_boundary_and_finite_lifetime(): + source, checksum = "123456789abc" + "d" * 28, "f" * 64 + hashes = {name: "b" * 64 for name in configuration.CONFIG_FILES} + receipt = { + "schema_version": 1, + "source_sha": source, + "release_id": "sd-123456789abc", + "configuration_id": configuration.configuration_id(source, checksum, hashes), + "manifest_sha256": checksum, + "configuration_files": hashes, + "operator_attested_measurement": False, + "installed": False, + "execution_verified": False, + } + contents = factory_service.render(receipt).decode() + assert " prepare-factory --config " in contents + assert " --lifetime-seconds 3600 --max-requests 4096" in contents + assert "KillMode=mixed" in contents and "Restart=no" in contents + assert "NoNewPrivileges" not in contents and "SystemCallFilter" not in contents + assert "PrivatePIDs" not in contents and "ProtectControlGroups" not in contents + assert "EnvironmentFile" not in contents and "LoadCredential" not in contents + assert "env -i" in contents and "MemorySwapMax=0" in contents + assert receipt["configuration_id"] in contents diff --git a/judge/crates/sandbox/src/factory_preparation.rs b/judge/crates/sandbox/src/factory_preparation.rs new file mode 100644 index 0000000..ed6c81f --- /dev/null +++ b/judge/crates/sandbox/src/factory_preparation.rs @@ -0,0 +1,205 @@ +//! Explicit fixed-node delegation preparation, separate from execution permits. +//! Only Sandbox owns these privileged kernel operations. + +use crate::{cgroup, execution, factory}; +use rustix::fs::{FlockOperation, Mode}; +use serde::{Deserialize, Serialize}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +const NODE: &str = "/sys/fs/cgroup/kfonlinejudge.slice"; +const CHILD: &str = "kf-judge-tasks"; +const FACTORY: &str = "/sys/fs/cgroup/kfonlinejudge.slice/kf-sandbox-factory.service"; + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct DelegationRecord { + schema_version: u8, + boot_id: String, + device: u64, + inode: u64, +} + +fn denied() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "factory preparation refused", + ) +} + +fn empty(path: &Path) -> io::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(_) => { + crate::isolation::root::open_directory(path)?; + if std::fs::read_dir(path)?.next().transpose()?.is_some() { + return Err(denied()); + } + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +pub fn prepare(config_path: &Path) -> io::Result { + factory::clean_environment()?; + let config = execution::load_config(config_path)?; + execution::authorize_registry(&config)?; + let policy = config.config(); + if policy.state_root != "/var/lib/kf-judge" + || policy.tasks_root != "/var/lib/kf-judge/tasks" + || policy.delegated_cgroup_root != format!("{NODE}/{CHILD}") + || policy.identities.worker_uid != 1202 + || policy.identities.worker_gid != 1202 + || policy.identities.first_task_uid != 100000 + || policy.identities.first_task_gid != 100000 + || policy.identities.count != 1 + || policy.max_concurrent_runs != 1 + { + return Err(denied()); + } + let own = std::fs::read_to_string("/proc/self/cgroup")?; + if own.trim() != format!("0::{}", FACTORY.trim_start_matches("/sys/fs/cgroup")) { + return Err(denied()); + } + let supervisor = crate::isolation::root::open_directory(Path::new(FACTORY))?; + if cgroup::read(&supervisor, "cgroup.procs")?.trim() != std::process::id().to_string() { + return Err(denied()); + } + // A helper will later apply its own required flags. Inherited filtering or + // no-new-privileges is not silently treated as a working execution gate. + let status = std::fs::read_to_string("/proc/self/status")?; + if !status.lines().any(|s| { + s.strip_prefix("NoNewPrivs:") + .is_some_and(|v| v.trim() == "0") + }) { + return Err(denied()); + } + let node = crate::isolation::root::open_directory(Path::new(NODE))?; + if rustix::fs::fstatfs(&node)?.f_type as u64 != 0x6367_7270 + || cgroup::read(&node, "cgroup.type")?.trim() != "domain" + || !cgroup::read(&node, "cgroup.procs")?.trim().is_empty() + { + return Err(denied()); + } + for (name, expected) in [ + ("memory.max", "4026531840"), + ("memory.swap.max", "0"), + ("pids.max", "384"), + ("cpu.max", "200000 100000"), + ] { + if cgroup::read(&node, name)?.trim() != expected { + return Err(denied()); + } + } + let controllers = cgroup::read(&node, "cgroup.subtree_control")?; + if !["cpu", "memory", "pids"] + .iter() + .all(|c| controllers.split_whitespace().any(|v| v == *c)) + { + return Err(denied()); + } + for name in ["tasks", "identities", "captures"] { + empty(&Path::new(&policy.state_root).join(name))?; + } + let root = Path::new(&policy.state_root).join("factory"); + if !root.try_exists()? { + std::fs::create_dir(&root)?; + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o711))?; + } + let root_fd = crate::isolation::root::open_directory(&root)?; + let lock = factory::private_file(&root.join("lock"))?; + rustix::fs::flock(&lock, FlockOperation::NonBlockingLockExclusive)?; + match std::fs::symlink_metadata(root.join("control.sock")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + Ok(_) => return Err(denied()), + } + for name in ["tasks", "identities", "captures"] { + empty(&Path::new(&policy.state_root).join(name))?; + } + let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")? + .trim() + .to_owned(); + if boot.len() != 36 || !boot.bytes().all(|c| c.is_ascii_hexdigit() || c == b'-') { + return Err(denied()); + } + let record_path = root.join("delegation.json"); + let old = if record_path.try_exists()? { + let mut file = factory::private_file(&record_path)?; + if file.metadata()?.len() > 1024 { + return Err(denied()); + } + let mut bytes = Vec::new(); + (&mut file).take(1025).read_to_end(&mut bytes)?; + let record: DelegationRecord = serde_json::from_slice(&bytes).map_err(|_| denied())?; + if record.schema_version != 1 || record.boot_id.len() != 36 { + return Err(denied()); + } + Some(record) + } else { + if std::fs::symlink_metadata(&record_path).is_ok() { + return Err(denied()); + } + None + }; + let delegated = Path::new(&policy.delegated_cgroup_root); + let existed = delegated.try_exists()?; + if existed { + let fd = crate::isolation::root::open_directory(delegated)?; + let st = rustix::fs::fstat(&fd)?; + if old.as_ref() + != Some(&DelegationRecord { + schema_version: 1, + boot_id: boot.clone(), + device: st.st_dev, + inode: st.st_ino, + }) + || !cgroup::read(&fd, "cgroup.procs")?.trim().is_empty() + { + return Err(denied()); + } + if std::fs::read_dir(delegated)?.any(|entry| { + entry.map_or(true, |e| { + e.file_type().map_or(true, |t| t.is_dir() || t.is_symlink()) + }) + }) { + return Err(denied()); + } + } else { + if std::fs::symlink_metadata(delegated).is_ok() + || old.as_ref().is_some_and(|record| record.boot_id == boot) + { + return Err(denied()); + } + // A new boot may recreate an empty delegation only after the persistent + // task/lease/capture checks above. Unexpected same-boot loss is refused. + rustix::fs::mkdirat(&node, CHILD, Mode::from_raw_mode(0o700))?; + } + // Existing descriptor-based checks apply/read back aggregate ceilings, + // no swap, controller availability and bounded depth/descendant counts. + let _group = cgroup::DelegatedCgroup::open(&config).map_err(|_| denied())?; + let fd = crate::isolation::root::open_directory(delegated)?; + let st = rustix::fs::fstat(&fd)?; + let record = DelegationRecord { + schema_version: 1, + boot_id: boot, + device: st.st_dev, + inode: st.st_ino, + }; + let bytes = serde_json::to_vec(&record)?; + let mut stored = factory::private_file(&record_path)?; + stored.set_len(0)?; + stored.write_all(&bytes)?; + stored.sync_all()?; + rustix::fs::fsync(&root_fd)?; + let actual = ["memory.max", "memory.swap.max", "pids.max", "cpu.max"] + .into_iter() + .map(|name| Ok((name, cgroup::read(&fd, name)?.trim().to_owned()))) + .collect::>>()?; + Ok(serde_json::json!({ + "delegation_prepared": true, "existing_owned_delegation": existed, + "actual_limits": actual, "services_started": false, "isolation_verified": false, + })) +} diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 42434ca..ed89af8 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -41,3 +41,5 @@ pub mod rpc; pub mod factory; #[cfg(target_os = "linux")] pub mod factory_cycles; +#[cfg(target_os = "linux")] +pub mod factory_preparation; diff --git a/judge/crates/sandbox/src/main.rs b/judge/crates/sandbox/src/main.rs index 8d5b0dc..04b68a7 100644 --- a/judge/crates/sandbox/src/main.rs +++ b/judge/crates/sandbox/src/main.rs @@ -63,6 +63,11 @@ enum Command { #[arg(long)] cycle_id: String, }, + /// Root fixed-node preparation; never grants an execution permit. + PrepareFactory { + #[arg(long)] + config: PathBuf, + }, #[cfg(target_os = "linux")] #[command(hide = true)] FactoryChild { @@ -202,6 +207,21 @@ fn main() -> ExitCode { spec_error("unsupported_platform", None) } } + Command::PrepareFactory { config } => { + #[cfg(target_os = "linux")] + match kf_sandbox::factory_preparation::prepare(&config) { + Ok(report) => { + println!("{report}"); + ExitCode::SUCCESS + } + Err(_) => spec_error("factory_preparation_refused", None), + } + #[cfg(not(target_os = "linux"))] + { + let _ = config; + spec_error("unsupported_platform", None) + } + } #[cfg(target_os = "linux")] Command::FactoryChild { config, run_id } => { // stdout is the root factory lifeline, never a log stream. diff --git a/scripts/release/README.md b/scripts/release/README.md index d3f3249..7f370cf 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -151,3 +151,17 @@ may leave a mixed set of pointers, but each pre-start check rejects an unselecte binding. Repeat installation can finish the same verified batch after stopping all app units. Loaded/inactive unit readback starts no service and makes no claim about effective runtime limits, readiness, Sandbox gates or VM acceptance. + +`python3.13 -m scripts.release.factory_service --output DIRECTORY` composes the +separate Root factory unit only after selection and protected configuration checks. +It requires matching composition code in that delivery, uses the fixed Root +binary and empty business environment, and preserves the supervisor reservations +and finite lifetime. It does not inherit the ordinary service namespace/filter +flags. Its pre-start `kf-sandbox prepare-factory` performs privileged cgroup +preparation inside Sandbox: the exact node/UID pool, single supervisor process, +empty task/lease/capture resources and factory lock are required. A retained +boot/inode record recognizes only its own empty delegation; an unknown group, +same-boot loss or conflicting record refuses. New-boot recreation requires empty +persistent resources. No journal/lease is deleted and preparation grants no +execution permit. Neither composition nor lexical tests are Root/kernel/service +acceptance; install, restart, failure recovery and actual execution remain VM-only. diff --git a/scripts/release/factory_service.py b/scripts/release/factory_service.py new file mode 100644 index 0000000..2501134 --- /dev/null +++ b/scripts/release/factory_service.py @@ -0,0 +1,115 @@ +"""Root factory unit composition, separate from ordinary service hardening.""" + +import argparse +import json +import os +from pathlib import Path + +from . import configuration, install, preflight, service_install, services +from .policy import DeliveryError, canonical, digest + + +def render(receipt: dict) -> bytes: + receipt = configuration.validate_receipt(receipt, installed=False) + release, batch = receipt["release_id"], receipt["configuration_id"] + payload = install.RELEASES / release / "payload" + binary = payload / "bin/kf-sandbox" + runtime = configuration.installed_path(release, batch, "runtime.json") + clean = "/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C " + values = [ + "[Unit]", + "Description=KF bounded Root helper factory", + "After=local-fs.target", + "[Service]", + "Type=exec", + "User=root", + "Group=root", + "Slice=kfonlinejudge.slice", + "WorkingDirectory=" + str(payload), + "UMask=0077", + "ExecStartPre=" + + clean + + services.python_environment(payload) + + f" {payload}/python/bin/python3.13 -s -P -m scripts.release.services check-binding" + + f" --release-id {release} --configuration-id {batch}", + "ExecStartPre=" + clean + str(binary) + " prepare-factory --config " + str(runtime), + "ExecStart=" + + clean + + str(binary) + + " factory --config " + + str(runtime) + + " --lifetime-seconds 3600 --max-requests 4096", + "MemoryMax=256M", + "MemorySwapMax=0", + "TasksMax=32", + "CPUQuota=50%", + "LimitCORE=0", + "LimitFSIZE=64M", + "LimitNOFILE=256", + "LimitSTACK=16M", + "TimeoutStartSec=1800", + "RuntimeMaxSec=3700", + "TimeoutStopSec=30", + "KillMode=mixed", + "Restart=no", + "StandardOutput=journal", + "StandardError=journal", + "LogRateLimitIntervalSec=30s", + "LogRateLimitBurst=100", + "[Install]", + "WantedBy=multi-user.target", + ] + return ("\n".join(values) + "\n").encode() + + +def generate(output: Path) -> dict: + if os.getuid() != 0 or os.geteuid() != 0: + raise DeliveryError("Root factory composition requires trusted operator") + selected, original = service_install.current_receipt() + payload = install.RELEASES / selected["release_id"] / "payload" + for name in ("factory_service.py", "services.py"): + installed = payload / "delivery/scripts/release" / name + preflight.protected(installed) + if digest(installed) != digest(Path(__file__).parent / name): + raise DeliveryError("factory composition differs from selected delivery") + preflight.protected(payload / "bin/kf-sandbox") + if output.exists() or output.is_symlink(): + raise DeliveryError("factory unit output must be new") + preflight.protected(output.parent, directory=True) + output.mkdir(mode=0o700) + unit = output / "kf-sandbox-factory.service" + unit.write_bytes(render(original)) + unit.chmod(0o600) + result = { + "schema_version": 1, + "source_sha": original["source_sha"], + "configuration_id": selected["configuration_id"], + "configuration_receipt_sha256": selected["receipt_sha256"], + "unit_sha256": digest(unit), + "installed": False, + "services_started": False, + "execution_verified": False, + } + receipt = output / "receipt.json" + receipt.write_bytes(canonical(result)) + receipt.chmod(0o600) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Protected Root factory unit composition") + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + try: + print(json.dumps(generate(args.output), sort_keys=True)) + except (OSError, ValueError) as error: + parser.exit( + 1, + "Factory composition refused: " + + (str(error) if isinstance(error, DeliveryError) else type(error).__name__) + + "\n", + ) + + +if __name__ == "__main__": + main()