307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
|
|
//! souveraine-admit — the admission executor.
|
||
|
|
//!
|
||
|
|
//! The only writer of agent Unix principals. It creates or adopts exactly one
|
||
|
|
//! account through `systemd-sysusers`, writes the root-owned agent→principal
|
||
|
|
//! mapping, and then reports the passwd facts it actually observes rather than
|
||
|
|
//! assuming its own command worked.
|
||
|
|
//!
|
||
|
|
//! What it deliberately does not do: read a human's home, install a worker
|
||
|
|
//! unit, move agent data, or recursively chown anything outside the agent's own
|
||
|
|
//! state root. Those are separate transitions. Until the worker exists, an
|
||
|
|
//! admitted agent still reads `acting-as-human` — this binary makes that state
|
||
|
|
//! reachable, it does not make it green.
|
||
|
|
//!
|
||
|
|
//! Contract: souveraine/saf/identity/02-agent-principal.md
|
||
|
|
|
||
|
|
#[path = "../core/identity/seed.rs"]
|
||
|
|
mod identity;
|
||
|
|
#[path = "../core/principal_map.rs"]
|
||
|
|
mod principal_map;
|
||
|
|
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
|
||
|
|
use anyhow::{bail, Context, Result};
|
||
|
|
use chrono::Utc;
|
||
|
|
use clap::{Parser, Subcommand};
|
||
|
|
|
||
|
|
use principal_map::*;
|
||
|
|
|
||
|
|
#[derive(Parser)]
|
||
|
|
#[command(
|
||
|
|
name = "souveraine-admit",
|
||
|
|
about = "Admit an agent to this node as her own Unix principal",
|
||
|
|
long_about = "Creates or adopts one Unix account for one agent, writes the root-owned\nagent-to-principal mapping, and reports what NSS says afterwards.\n\nIntent lives in the agent record and is written by the server. Admission is\nthis binary and needs root. Neither one starts a worker: until per-agent\nworkers exist, an admitted agent still reports acting-as-human."
|
||
|
|
)]
|
||
|
|
struct Cli {
|
||
|
|
/// Operate on an alternate filesystem root (image prep, tests)
|
||
|
|
#[arg(long, global = true, default_value = "/")]
|
||
|
|
root: PathBuf,
|
||
|
|
/// Speak in data
|
||
|
|
#[arg(long, global = true)]
|
||
|
|
json: bool,
|
||
|
|
#[command(subcommand)]
|
||
|
|
command: Command,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Subcommand)]
|
||
|
|
enum Command {
|
||
|
|
/// Report the admission facts for one agent. Writes nothing.
|
||
|
|
Status { agent_id: String },
|
||
|
|
/// Create or adopt the account and write the mapping. Idempotent.
|
||
|
|
Apply {
|
||
|
|
agent_id: String,
|
||
|
|
/// Local account this agent runs as
|
||
|
|
#[arg(long)]
|
||
|
|
account: String,
|
||
|
|
/// The agent's SeedID public key (hex). Recorded so a copied record
|
||
|
|
/// cannot inherit an admitted account.
|
||
|
|
#[arg(long, default_value = "")]
|
||
|
|
seed_id: String,
|
||
|
|
/// Print the plan and touch nothing
|
||
|
|
#[arg(long)]
|
||
|
|
dry_run: bool,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
fn main() -> Result<()> {
|
||
|
|
let cli = Cli::parse();
|
||
|
|
match &cli.command {
|
||
|
|
Command::Status { agent_id } => status(&cli, agent_id),
|
||
|
|
Command::Apply {
|
||
|
|
agent_id,
|
||
|
|
account,
|
||
|
|
seed_id,
|
||
|
|
dry_run,
|
||
|
|
} => apply(&cli, agent_id, account, seed_id, *dry_run),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn status(cli: &Cli, agent_id: &str) -> Result<()> {
|
||
|
|
let mapping = load_node_mapping(&cli.root, agent_id);
|
||
|
|
let account = mapping
|
||
|
|
.as_ref()
|
||
|
|
.and_then(|m| account_lookup(&cli.root, &m.account));
|
||
|
|
|
||
|
|
if cli.json {
|
||
|
|
println!(
|
||
|
|
"{}",
|
||
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
||
|
|
"agent_id": agent_id,
|
||
|
|
"mapping": mapping,
|
||
|
|
"account_exists": account.is_some(),
|
||
|
|
"account_uid": account.as_ref().map(|a| a.uid),
|
||
|
|
"account_shell": account.as_ref().map(|a| a.shell.clone()),
|
||
|
|
"worker_installed": false,
|
||
|
|
}))?
|
||
|
|
);
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
|
||
|
|
match (&mapping, &account) {
|
||
|
|
(None, _) => println!("unadmitted — no mapping for {agent_id} on this node"),
|
||
|
|
(Some(m), None) => println!(
|
||
|
|
"principal-drift — mapped to `{}` (uid {}), which does not exist here",
|
||
|
|
m.account, m.uid
|
||
|
|
),
|
||
|
|
(Some(m), Some(a)) if a.uid != m.uid => println!(
|
||
|
|
"principal-drift — mapping says uid {} but `{}` is uid {}",
|
||
|
|
m.uid, m.account, a.uid
|
||
|
|
),
|
||
|
|
(Some(m), Some(a)) => {
|
||
|
|
println!("admitted {} → {} (uid {})", agent_id, m.account, a.uid);
|
||
|
|
println!("state root {}", m.state_root);
|
||
|
|
println!("shell {}", a.shell);
|
||
|
|
println!("seed {}", glyph_or_none(&m.agent_seed_id));
|
||
|
|
println!("worker none — turns still run as the invoking user");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn apply(cli: &Cli, agent_id: &str, account: &str, seed_id: &str, dry_run: bool) -> Result<()> {
|
||
|
|
let alternate_root = cli.root != Path::new("/");
|
||
|
|
if !alternate_root && unsafe { libc::geteuid() } != 0 && !dry_run {
|
||
|
|
bail!("admission needs root; re-run with sudo, or use --root for an image or test tree");
|
||
|
|
}
|
||
|
|
validate_account_name(account)?;
|
||
|
|
if agent_id.is_empty() || agent_id.contains('/') || agent_id.contains("..") {
|
||
|
|
bail!("refusing agent id `{agent_id}`: it becomes a filename");
|
||
|
|
}
|
||
|
|
if !seed_id.is_empty() && (seed_id.len() != 64 || !seed_id.bytes().all(|b| b.is_ascii_hexdigit()))
|
||
|
|
{
|
||
|
|
bail!("--seed-id must be a 64-character hex public key");
|
||
|
|
}
|
||
|
|
|
||
|
|
// An account belongs to at most one agent. Adopting one that another agent
|
||
|
|
// already holds is the exact collapse per-agent principals exist to stop.
|
||
|
|
if let Some(owner) = mapping_owner_of_account(&cli.root, account) {
|
||
|
|
if owner != agent_id {
|
||
|
|
bail!("account `{account}` is already admitted to agent {owner}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A pre-existing mapping for a *different* account is a rename, not a
|
||
|
|
// repair. Refuse rather than strand the old account and its data.
|
||
|
|
let existing = load_node_mapping(&cli.root, agent_id);
|
||
|
|
if let Some(prior) = &existing {
|
||
|
|
if prior.account != account {
|
||
|
|
bail!(
|
||
|
|
"agent {agent_id} is already admitted as `{}`; decommission before admitting `{account}`",
|
||
|
|
prior.account
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if !prior.agent_seed_id.is_empty() && !seed_id.is_empty() && prior.agent_seed_id != seed_id {
|
||
|
|
bail!("mapping records a different SeedID for {agent_id}; this is identity drift, not a repair");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Adopting an existing human login would hand an agent a person's account.
|
||
|
|
if let Some(found) = account_lookup(&cli.root, account) {
|
||
|
|
if found.uid >= HUMAN_UID_FLOOR && existing.is_none() {
|
||
|
|
bail!(
|
||
|
|
"`{account}` already exists as uid {} — that is a login account, not a free agent principal",
|
||
|
|
found.uid
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let state_root = agent_state_root(&cli.root, agent_id);
|
||
|
|
let state_root_abs = Path::new("/")
|
||
|
|
.join(AGENT_STATE_ROOT)
|
||
|
|
.join(agent_id)
|
||
|
|
.to_string_lossy()
|
||
|
|
.into_owned();
|
||
|
|
let drop_in = sysusers_drop_in_path(&cli.root, agent_id);
|
||
|
|
let declaration = format!(
|
||
|
|
"# Generated by souveraine-admit for agent {agent_id}. Do not hand-edit:\n\
|
||
|
|
# the mapping in /{PRINCIPAL_MAP_DIR}/{agent_id}.json is the record of\n\
|
||
|
|
# truth and health compares this file against it.\n\
|
||
|
|
u {account} - \"Souveraine agent {agent_id}\" {state_root_abs} /usr/bin/nologin\n"
|
||
|
|
);
|
||
|
|
|
||
|
|
if dry_run {
|
||
|
|
println!("would write {}", drop_in.display());
|
||
|
|
print!("{declaration}");
|
||
|
|
println!("would run systemd-sysusers {}", drop_in.display());
|
||
|
|
println!("would write {}", node_mapping_path(&cli.root, agent_id).display());
|
||
|
|
println!("would own {} as {account}", state_root.display());
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
|
||
|
|
write_file(&drop_in, &declaration, 0o644)?;
|
||
|
|
run_sysusers(&cli.root, &drop_in)?;
|
||
|
|
|
||
|
|
// Report what the system says, never what the command intended.
|
||
|
|
let created = account_lookup(&cli.root, account).ok_or_else(|| {
|
||
|
|
anyhow::anyhow!("systemd-sysusers reported success but `{account}` still does not resolve")
|
||
|
|
})?;
|
||
|
|
if created.shell != "/usr/bin/nologin" {
|
||
|
|
bail!(
|
||
|
|
"`{account}` resolved with shell {} — refusing to record a login-capable agent principal",
|
||
|
|
created.shell
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
let mapping = NodePrincipalMapping {
|
||
|
|
agent_id: agent_id.to_string(),
|
||
|
|
agent_seed_id: if seed_id.is_empty() {
|
||
|
|
existing.as_ref().map(|p| p.agent_seed_id.clone()).unwrap_or_default()
|
||
|
|
} else {
|
||
|
|
seed_id.to_string()
|
||
|
|
},
|
||
|
|
account: created.name.clone(),
|
||
|
|
uid: created.uid,
|
||
|
|
node_id: node_id(&cli.root),
|
||
|
|
state_root: state_root_abs,
|
||
|
|
admitted_at: existing.as_ref().map(|p| p.admitted_at).unwrap_or_else(Utc::now),
|
||
|
|
};
|
||
|
|
write_file(
|
||
|
|
&node_mapping_path(&cli.root, agent_id),
|
||
|
|
&serde_json::to_string_pretty(&mapping)?,
|
||
|
|
0o644,
|
||
|
|
)?;
|
||
|
|
|
||
|
|
// Ownership is applied inside her own root only. Never a human home, never
|
||
|
|
// a recursive pass over ~/.souveraine.
|
||
|
|
std::fs::create_dir_all(&state_root)
|
||
|
|
.with_context(|| format!("creating {}", state_root.display()))?;
|
||
|
|
if unsafe { libc::geteuid() } == 0 {
|
||
|
|
chown(&state_root, created.uid)?;
|
||
|
|
restrict(&state_root, 0o700)?;
|
||
|
|
}
|
||
|
|
|
||
|
|
if cli.json {
|
||
|
|
println!("{}", serde_json::to_string_pretty(&mapping)?);
|
||
|
|
} else {
|
||
|
|
println!("admitted {} → {} (uid {})", agent_id, mapping.account, mapping.uid);
|
||
|
|
println!("state root {}", mapping.state_root);
|
||
|
|
println!("seed {}", glyph_or_none(&mapping.agent_seed_id));
|
||
|
|
println!("worker none — her turns still run as whoever invokes them");
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn run_sysusers(root: &Path, drop_in: &Path) -> Result<()> {
|
||
|
|
let mut cmd = std::process::Command::new("systemd-sysusers");
|
||
|
|
if root != Path::new("/") {
|
||
|
|
cmd.arg(format!("--root={}", root.display()));
|
||
|
|
}
|
||
|
|
cmd.arg(drop_in);
|
||
|
|
let out = cmd.output().context("running systemd-sysusers")?;
|
||
|
|
if !out.status.success() {
|
||
|
|
bail!(
|
||
|
|
"systemd-sysusers failed: {}",
|
||
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn write_file(path: &Path, contents: &str, mode: u32) -> Result<()> {
|
||
|
|
use std::os::unix::fs::PermissionsExt;
|
||
|
|
if let Some(parent) = path.parent() {
|
||
|
|
std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||
|
|
}
|
||
|
|
std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
|
||
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn restrict(path: &Path, mode: u32) -> Result<()> {
|
||
|
|
use std::os::unix::fs::PermissionsExt;
|
||
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn chown(path: &Path, uid: u32) -> Result<()> {
|
||
|
|
use std::ffi::CString;
|
||
|
|
use std::os::unix::ffi::OsStrExt;
|
||
|
|
let c = CString::new(path.as_os_str().as_bytes())?;
|
||
|
|
if unsafe { libc::chown(c.as_ptr(), uid, uid) } != 0 {
|
||
|
|
bail!(
|
||
|
|
"chown {} to {uid}: {}",
|
||
|
|
path.display(),
|
||
|
|
std::io::Error::last_os_error()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn node_id(root: &Path) -> String {
|
||
|
|
std::fs::read_to_string(root.join("etc/hostname"))
|
||
|
|
.ok()
|
||
|
|
.map(|v| v.trim().to_string())
|
||
|
|
.filter(|v| !v.is_empty())
|
||
|
|
.unwrap_or_else(|| "unknown".to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn glyph_or_none(seed_hex: &str) -> String {
|
||
|
|
if seed_hex.is_empty() {
|
||
|
|
return "none recorded".to_string();
|
||
|
|
}
|
||
|
|
let bytes: Vec<u8> = (0..seed_hex.len().min(4))
|
||
|
|
.step_by(2)
|
||
|
|
.filter_map(|i| u8::from_str_radix(&seed_hex[i..i + 2], 16).ok())
|
||
|
|
.collect();
|
||
|
|
format!("{} {}", identity::glyph_from_pubkey(&bytes), &seed_hex[..16])
|
||
|
|
}
|