feat(sandbox): prepare protected single-node factory delegation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
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::<io::Result<std::collections::BTreeMap<_, _>>>()?;
|
||||
Ok(serde_json::json!({
|
||||
"delegation_prepared": true, "existing_owned_delegation": existed,
|
||||
"actual_limits": actual, "services_started": false, "isolation_verified": false,
|
||||
}))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user