Watch
1
0
Fork
You've already forked souveraine
0

machined: system-tier machine identity daemon

The machine seed moves out of the user session: souveraine-machined owns
/var/lib/souveraine/seed-id as the souveraine system user and serves
pubkey/sign over /run/souveraine/machined.sock (SO_PEERCRED logged,
domain-separated signatures, group-gated socket). Seed is a precondition
- provision via 'souveraine machine init --fresh|--migrate-from', the
daemon never generates. reach/consult now resolves the machine key
through the daemon with a loud legacy fallback. Hardened system unit +
sysusers shipped in the Arch package.
This commit is contained in:
Fimeg 2026-07-16 10:08:33 -04:00
commit 5039163a52
12 changed files with 796 additions and 5 deletions

1
Cargo.lock generated
View file

@ -9094,6 +9094,7 @@ dependencies = [
"image",
"keyring",
"keyring-core",
"libc",
"matrix-sdk",
"notify",
"num-bigint",

View file

@ -102,6 +102,8 @@ keyring-core = "1"
ed25519-dalek = { version = "2", features = ["rand_core", "pem"] }
sha2 = "0.10"
rand = "0.8"
# SO_PEERCRED on the machined socket — the daemon logs who asked for what
libc = "0.2"
# Cron expression parsing (schedule system)
cron = "0.13"
@ -179,6 +181,12 @@ name = "souveraine-secrets"
path = "src/bin/souveraine-secrets.rs"
required-features = ["secrets"]
# System-tier machine identity daemon. No feature gate: zero new heavy deps,
# and the phone package should always ship it.
[[bin]]
name = "souveraine-machined"
path = "src/bin/souveraine-machined.rs"
[profile.release]
opt-level = 3
lto = true

View file

@ -34,5 +34,15 @@ package() {
# runs the pacman-owned binary
sed 's|%h/.local/bin/souveraine|/usr/bin/souveraine|' packaging/souveraine.service \
| install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/user/souveraine.service"
# System tier: machine identity daemon + its service user. pacman runs
# systemd-sysusers on install, so the `souveraine` user exists before
# anyone runs `souveraine machine init`.
install -Dm755 target/release/souveraine-machined "$pkgdir/usr/bin/souveraine-machined"
install -Dm644 packaging/souveraine-machined.service \
"$pkgdir/usr/lib/systemd/system/souveraine-machined.service"
install -Dm644 packaging/arch/souveraine.sysusers \
"$pkgdir/usr/lib/sysusers.d/souveraine.conf"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

View file

@ -0,0 +1,5 @@
# The `souveraine` system user owns the machine's system tier: the machine
# seed (/var/lib/souveraine) and the machined socket (/run/souveraine).
# Session users join the group to talk to the daemon:
# usermod -aG souveraine <user>
u souveraine - "SouveraineOS system tier" /var/lib/souveraine -

View file

@ -0,0 +1,54 @@
# Souveraine machine identity — SYSTEM unit (contrast souveraine.service,
# which is a user unit). Runs from boot, before any human authenticates;
# owns the machine seed and serves signatures over a Unix socket.
#
# Access control is the socket: /run/souveraine (0750 souveraine:souveraine)
# + machined.sock (0660). Add session users to the `souveraine` group:
# usermod -aG souveraine <user>
#
# Provision the seed before first start:
# sudo souveraine machine init --fresh
# sudo souveraine machine init --migrate-from /home/<user>/.souveraine/seed-id
[Unit]
Description=Souveraine machine identity (system tier)
[Service]
User=souveraine
Group=souveraine
ExecStart=/usr/bin/souveraine-machined
Restart=on-failure
RestartSec=5
# /var/lib/souveraine — the seed. /run/souveraine — the socket.
StateDirectory=souveraine
StateDirectoryMode=0700
RuntimeDirectory=souveraine
RuntimeDirectoryMode=0750
UMask=0007
# Network-less signer, RedFlag-executor posture: nothing to reach, nothing
# reachable. AF_UNIX only, no privileges, no writable system.
NoNewPrivileges=yes
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX
IPAddressDeny=any
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,69 @@
//! souveraine-machined — system-tier machine identity daemon.
//!
//! Owns the machine's Ed25519 seed at `/var/lib/souveraine/seed-id` and
//! serves pubkey/sign requests over `/run/souveraine/machined.sock`. The
//! private key never crosses the socket; user-tier processes (agents, the
//! federation bridge, the shell) get signatures, not key material.
//!
//! The seed is a precondition, never something this daemon creates. If no
//! seed exists, startup fails loudly — identity creation is a deliberate,
//! guarded action that lives in `souveraine machine init`, not something a
//! background service does on your behalf. Same doctrine as
//! `souveraine-secrets`; see `SeedId::load` in `core/identity/seed.rs`.
#[path = "../core/identity/seed.rs"]
mod identity;
#[path = "../machined/protocol.rs"]
mod protocol;
#[path = "../machined/server.rs"]
mod server;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let mut seed_dir = PathBuf::from(protocol::DEFAULT_SEED_DIR);
let mut socket = PathBuf::from(protocol::DEFAULT_SOCKET_PATH);
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--seed-dir" => {
seed_dir = PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--seed-dir requires a path")
})?);
}
"--socket" => {
socket = PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--socket requires a path")
})?);
}
"--help" | "-h" => {
println!(
"souveraine-machined [--seed-dir {}] [--socket {}]",
protocol::DEFAULT_SEED_DIR,
protocol::DEFAULT_SOCKET_PATH
);
return Ok(());
}
other => anyhow::bail!("unknown argument: {other}"),
}
}
let seed = identity::SeedId::load(&seed_dir).map_err(|e| {
anyhow::anyhow!(
"{e:#}\n\nsouveraine-machined refuses to start without a machine identity.\n\
Provision one: sudo souveraine machine init --fresh\n\
Or carry an existing identity over: sudo souveraine machine init \
--migrate-from ~/.souveraine/seed-id"
)
})?;
server::run(seed, &socket)
}

View file

@ -133,11 +133,13 @@ fn dispatch_summon(
})?;
// The *machine* seed addresses this box — it is the reply route, and the
// bridge signs the transport envelope with it.
let machine_seed_id = SeedId::load_or_generate(&SeedId::default_dir(&base))
.map(|s| s.public_key_hex())
// bridge signs the transport envelope with it. System tier (machined)
// answers first; the legacy user-tier seed is a loud transitional
// fallback that never generates.
let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base)
.map(|(pk, _source)| pk)
.map_err(|e| ToolError::invalid_input(&format!(
"I couldn't load my machine seed identity: {e}"
"I couldn't establish my machine identity: {e:#}"
)))?;
// The *agent* seed proves who is reaching. It lives beside the memfs

133
src/machined/client.rs Normal file
View file

@ -0,0 +1,133 @@
//! Client for souveraine-machined — how user-tier processes reach the
//! system-tier machine identity.
//!
//! Synchronous by design: one tiny local round-trip, callable from both sync
//! tool code and async server code without ceremony.
//!
//! The legacy fallback exists because deployed machines still carry their
//! machine seed at `~/.souveraine/seed-id` from before the system tier
//! existed. It is transitional, loud, and strict — it will `load` an
//! existing legacy seed but never generate one. New identity comes only
//! from `souveraine machine init`.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::warn;
use super::protocol::{Request, DEFAULT_SOCKET_PATH};
use crate::core::identity::SeedId;
/// Where the machine seed identity came from — callers that report or audit
/// should say which tier answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MachineIdentitySource {
/// The system-tier daemon answered on its socket.
Machined,
/// Transitional: read from the legacy user-tier seed directory.
LegacyUserSeed,
}
/// Test hook only — points the client at a scratch socket. Not a deployment
/// knob; production callers always use the default path.
pub fn socket_path() -> PathBuf {
std::env::var_os("SOUVERAINE_MACHINED_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_PATH))
}
fn request(req: &Request) -> Result<serde_json::Value> {
let path = socket_path();
let stream = UnixStream::connect(&path)
.with_context(|| format!("connecting to souveraine-machined at {}", path.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut writer = stream.try_clone()?;
writer.write_all(serde_json::to_string(req)?.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut line = String::new();
BufReader::new(stream)
.read_line(&mut line)
.context("reading souveraine-machined response")?;
let value: serde_json::Value =
serde_json::from_str(line.trim()).context("parsing souveraine-machined response")?;
if value.get("ok").and_then(|v| v.as_bool()) == Some(true) {
Ok(value)
} else {
let reason = value
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unspecified refusal");
anyhow::bail!("souveraine-machined refused: {reason}")
}
}
/// Daemon status as reported by the daemon itself.
pub fn status() -> Result<serde_json::Value> {
request(&Request::Status)
}
/// Machine public key + glyph from the daemon.
pub fn pubkey() -> Result<(String, String)> {
let value = request(&Request::Pubkey)?;
let pk = value
.get("public_key")
.and_then(|v| v.as_str())
.context("response missing public_key")?
.to_string();
let glyph = value
.get("glyph")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
Ok((pk, glyph))
}
/// Domain-separated signature over `payload`. Returns the signature hex;
/// the signed bytes are `protocol::signing_bytes(domain, payload)`.
#[allow(dead_code)] // phase 2: federation envelope signing routes through machined
pub fn sign(domain: &str, payload: &[u8]) -> Result<String> {
let value = request(&Request::Sign {
domain: domain.to_string(),
payload_hex: hex::encode(payload),
})?;
value
.get("signature")
.and_then(|v| v.as_str())
.map(str::to_string)
.context("response missing signature")
}
/// Resolve this box's machine public key: system tier first, legacy user
/// seed as a loud transitional fallback. Never generates — a machine with
/// no identity is an error with provisioning instructions, not a fresh key.
pub fn machine_pubkey_with_fallback(base: &Path) -> Result<(String, MachineIdentitySource)> {
match pubkey() {
Ok((pk, _glyph)) => return Ok((pk, MachineIdentitySource::Machined)),
Err(e) => {
warn!(
"souveraine-machined unavailable ({e:#}); falling back to legacy \
user-tier machine seed provision the system tier with \
`sudo souveraine machine init`"
);
}
}
let legacy_dir = SeedId::default_dir(base);
let seed = SeedId::load(&legacy_dir).with_context(|| {
format!(
"no machine identity: souveraine-machined is not running and no legacy \
seed exists at {} provision one with `sudo souveraine machine init --fresh` \
(or `--migrate-from <dir>` to carry an existing identity over)",
legacy_dir.display()
)
})?;
Ok((seed.public_key_hex(), MachineIdentitySource::LegacyUserSeed))
}

16
src/machined/mod.rs Normal file
View file

@ -0,0 +1,16 @@
//! souveraine-machined — the system tier's machine identity.
//!
//! SouveraineOS splits identity into two tiers. The **machine** (seed at
//! `/var/lib/souveraine/seed-id`, owned by the `souveraine` system user,
//! served by the `souveraine-machined` daemon) exists from boot, before any
//! human authenticates — it is what enrolls with central authority, signs
//! federation transport, and anchors node commissions. **Agents** (per-agent
//! seeds beside their memfs) belong to the user tier and exist only inside
//! an authenticated session.
//!
//! This module carries the wire protocol and the user-tier client. The serve
//! loop (`server.rs`) is compiled only into the `souveraine-machined` bin,
//! the same `#[path]` pattern as `souveraine-secrets`.
pub mod client;
pub mod protocol;

103
src/machined/protocol.rs Normal file
View file

@ -0,0 +1,103 @@
//! Wire protocol for souveraine-machined — the system-tier machine identity
//! daemon.
//!
//! One JSON object per line over a Unix socket. Responses follow the guarded
//! `ok`/`reason` pattern used by the session IPC surface: refusals always
//! carry a reason, never a silent failure.
//!
//! Signatures are domain-separated: the daemon never signs caller-supplied
//! bytes raw. A machined signature can therefore never be confused with (or
//! replayed as) a federation envelope, a node commission, or any other
//! payload signed by the same key outside this protocol.
// Shared with the souveraine-machined bin via #[path]; the main crate uses
// only a subset of these items.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
/// Where the daemon listens. `RuntimeDirectory=souveraine` in the unit owns
/// the parent; the socket itself is group-rw so members of the `souveraine`
/// group may connect.
pub const DEFAULT_SOCKET_PATH: &str = "/run/souveraine/machined.sock";
/// Where the machine seed lives on the system tier. Root-of-trust for this
/// box; provisioned deliberately via `souveraine machine init`, never by the
/// daemon itself.
pub const DEFAULT_SEED_DIR: &str = "/var/lib/souveraine/seed-id";
/// Upper bound on one request line. Anything longer is refused, not read.
pub const MAX_REQUEST_BYTES: u64 = 64 * 1024;
/// Version tag baked into every signature's domain separation.
pub const SIGNING_CONTEXT: &str = "souveraine-machined:v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
/// Daemon liveness + identity summary.
Status,
/// Public key + glyph only.
Pubkey,
/// Sign `payload_hex` under `domain`. The signed bytes are
/// `{SIGNING_CONTEXT}:{domain}:{payload}` — see [`signing_bytes`].
Sign { domain: String, payload_hex: String },
}
/// The exact bytes a machined signature covers.
pub fn signing_bytes(domain: &str, payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(SIGNING_CONTEXT.len() + domain.len() + payload.len() + 2);
bytes.extend_from_slice(SIGNING_CONTEXT.as_bytes());
bytes.push(b':');
bytes.extend_from_slice(domain.as_bytes());
bytes.push(b':');
bytes.extend_from_slice(payload);
bytes
}
/// Domains are short ASCII labels. Excluding `:` keeps [`signing_bytes`]
/// framing unambiguous.
pub fn valid_domain(domain: &str) -> bool {
!domain.is_empty()
&& domain.len() <= 64
&& domain
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signing_bytes_are_framed() {
let bytes = signing_bytes("federation-envelope", b"payload");
assert_eq!(
bytes,
b"souveraine-machined:v1:federation-envelope:payload".to_vec()
);
}
#[test]
fn domain_charset_is_enforced() {
assert!(valid_domain("federation-envelope"));
assert!(valid_domain("node.commission_v1"));
assert!(!valid_domain(""));
assert!(!valid_domain("has:colon"));
assert!(!valid_domain("has space"));
assert!(!valid_domain(&"x".repeat(65)));
}
#[test]
fn request_round_trips() {
let req: Request =
serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap();
match req {
Request::Sign { domain, payload_hex } => {
assert_eq!(domain, "d");
assert_eq!(payload_hex, "00ff");
}
_ => panic!("wrong variant"),
}
}
}

213
src/machined/server.rs Normal file
View file

@ -0,0 +1,213 @@
//! Serve loop for souveraine-machined.
//!
//! Deliberately synchronous, std-only: one small thread per connection, a
//! few requests per client, everything auditable in one sitting. The daemon
//! is the only process that reads the machine private key; callers get
//! signatures and the public key over the socket, never the key itself.
//!
//! Compiled into the `souveraine-machined` bin via `#[path]` includes, the
//! same pattern as `souveraine-secrets` — `crate::identity` and
//! `crate::protocol` below resolve to the bin's module tree.
use std::io::{BufRead, BufReader, Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::identity::SeedId;
use crate::protocol::{self, Request, MAX_REQUEST_BYTES};
/// SO_PEERCRED identity of the connecting process. Logged on every request
/// so the audit trail names the caller, not just the call.
#[derive(Debug, Clone, Copy)]
struct PeerCred {
pid: i32,
uid: u32,
gid: u32,
}
fn peer_cred(stream: &UnixStream) -> Option<PeerCred> {
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd
// is live for the duration of the call because we hold &UnixStream.
let rc = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut libc::ucred as *mut libc::c_void,
&mut len,
)
};
if rc == 0 {
Some(PeerCred { pid: cred.pid, uid: cred.uid, gid: cred.gid })
} else {
None
}
}
pub fn run(seed: SeedId, socket_path: &Path) -> Result<()> {
// A live daemon answers on the socket; a stale file from a crash does
// not. Refuse to double-bind, clean up only what is genuinely dead.
if socket_path.exists() {
if UnixStream::connect(socket_path).is_ok() {
anyhow::bail!(
"another souveraine-machined is already serving {}",
socket_path.display()
);
}
warn!("removing stale socket at {}", socket_path.display());
std::fs::remove_file(socket_path)
.with_context(|| format!("removing stale socket {}", socket_path.display()))?;
}
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("binding {}", socket_path.display()))?;
// Group-rw: the RuntimeDirectory's 0750 + this 0660 make membership in
// the `souveraine` group the access control. No auth theater on top —
// the socket permission IS the policy, and every request is logged.
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o660))
.with_context(|| format!("setting socket permissions on {}", socket_path.display()))?;
info!(
"machine identity {} ({}) serving on {}",
seed.glyph(),
seed.public_key_hex(),
socket_path.display()
);
// Signing is &self; the key never needs to be copied, only shared.
let seed = Arc::new(seed);
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(e) => {
warn!("accept failed: {e}");
continue;
}
};
let seed = Arc::clone(&seed);
std::thread::spawn(move || handle_connection(stream, seed));
}
Ok(())
}
fn handle_connection(stream: UnixStream, seed: Arc<SeedId>) {
let cred = peer_cred(&stream);
let (uid, gid, pid) = match cred {
Some(c) => (c.uid, c.gid, c.pid),
None => {
warn!("connection without readable peer credentials — refusing");
return;
}
};
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30)));
let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(10)));
let mut writer = match stream.try_clone() {
Ok(w) => w,
Err(e) => {
warn!("could not clone stream for uid={uid} pid={pid}: {e}");
return;
}
};
let mut reader = BufReader::new(stream);
loop {
let mut line = String::new();
// take() caps a single request; a client shoveling an oversized line
// gets a refusal and the connection dropped, not an OOM.
match (&mut reader).take(MAX_REQUEST_BYTES).read_line(&mut line) {
Ok(0) => return, // clean EOF
Ok(_) => {}
Err(e) => {
warn!("read error from uid={uid} pid={pid}: {e}");
return;
}
}
if line.len() as u64 >= MAX_REQUEST_BYTES {
let _ = respond(&mut writer, refusal("request exceeds size limit"));
warn!("oversized request from uid={uid} pid={pid} — connection dropped");
return;
}
let line = line.trim();
if line.is_empty() {
continue;
}
let response = match serde_json::from_str::<Request>(line) {
Ok(req) => handle_request(&seed, req, uid, gid, pid),
Err(e) => {
warn!("malformed request from uid={uid} pid={pid}: {e}");
refusal(&format!("malformed request: {e}"))
}
};
if respond(&mut writer, response).is_err() {
return;
}
}
}
fn handle_request(
seed: &SeedId,
req: Request,
uid: u32,
gid: u32,
pid: i32,
) -> serde_json::Value {
match req {
Request::Status => serde_json::json!({
"ok": true,
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
"signing_context": protocol::SIGNING_CONTEXT,
}),
Request::Pubkey => serde_json::json!({
"ok": true,
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
}),
Request::Sign { domain, payload_hex } => {
if !protocol::valid_domain(&domain) {
warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}");
return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars");
}
let payload = match hex::decode(&payload_hex) {
Ok(p) => p,
Err(e) => {
warn!("sign refused for uid={uid} pid={pid} domain={domain}: bad payload hex: {e}");
return refusal("payload_hex is not valid hex");
}
};
let bytes = protocol::signing_bytes(&domain, &payload);
let signature = hex::encode(seed.sign(&bytes).to_bytes());
info!(
"signed domain={domain} payload_len={} for uid={uid} gid={gid} pid={pid}",
payload.len()
);
serde_json::json!({
"ok": true,
"signature": signature,
"public_key": seed.public_key_hex(),
"domain": domain,
})
}
}
}
fn refusal(reason: &str) -> serde_json::Value {
serde_json::json!({ "ok": false, "reason": reason })
}
fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> {
writer.write_all(value.to_string().as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()
}

View file

@ -10,6 +10,7 @@ mod cli;
mod core;
mod harness;
mod interface;
mod machined;
mod server;
mod ui;
@ -224,6 +225,16 @@ and — with [federation].auto_wake — spawns the full server to answer them.")
host: bool,
},
/// System-tier machine identity (souveraine-machined)
#[command(long_about = "Provision and inspect the machine identity served by \
souveraine-machined. The machine seed lives at /var/lib/souveraine/seed-id, \
owned by the `souveraine` system user it exists from boot, before any \
human authenticates, and is distinct from per-agent seeds.")]
Machine {
#[command(subcommand)]
action: MachineAction,
},
/// Show known federated peers
#[command(long_about = "Show all peers tracked by the device registry, from federation announcements.")]
Peers {
@ -299,6 +310,22 @@ enum IdentityAction {
},
}
#[derive(Subcommand)]
enum MachineAction {
/// Provision the machine seed (run with sudo; requires --fresh or --migrate-from)
Init {
/// Generate a brand-new machine identity
#[arg(long)]
fresh: bool,
/// Copy an existing seed directory (e.g. ~/.souveraine/seed-id) so
/// federation trust in this machine survives the tier move
#[arg(long, value_name = "DIR", conflicts_with = "fresh")]
migrate_from: Option<PathBuf>,
},
/// Show daemon + seed state
Status,
}
#[derive(Subcommand)]
enum EventsAction {
/// Show recent events from the firehose
@ -382,6 +409,11 @@ async fn main() -> anyhow::Result<()> {
return run_events(action, cli.json).await;
}
// Handle machine early — system-tier identity, no backend needed
if let Some(Commands::Machine { action }) = &cli.command {
return run_machine(action, cli.json);
}
// Handle peers early — reads known_peers.json, no backend needed
if let Some(Commands::Peers { json }) = &cli.command {
return run_peers(*json).await;
@ -403,7 +435,7 @@ async fn main() -> anyhow::Result<()> {
Commands::Reflect { conversation } => {
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
}
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(),
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Machine { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(),
}
Ok(())
@ -665,6 +697,151 @@ async fn run_identity(
Ok(())
}
fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> {
use crate::core::identity::SeedId;
use crate::machined::protocol::DEFAULT_SEED_DIR;
let seed_dir = PathBuf::from(DEFAULT_SEED_DIR);
match action {
MachineAction::Init { fresh, migrate_from } => {
if seed_dir.join("private.key").exists() {
anyhow::bail!(
"A machine identity already exists at {}. Refusing to overwrite — \
replacing it breaks federation trust and node commissions. \
Remove it manually first if you truly mean to.",
seed_dir.display()
);
}
// Identity creation is deliberate: the operator states whether
// this machine is new or carries an existing identity forward.
let seed = match (fresh, migrate_from) {
(true, None) => {
std::fs::create_dir_all(&seed_dir)?;
SeedId::load_or_generate(&seed_dir)?
}
(false, Some(source)) => {
let source_key = source.join("private.key");
if !source_key.exists() {
anyhow::bail!(
"no seed at {} — nothing to migrate",
source_key.display()
);
}
std::fs::create_dir_all(&seed_dir)?;
std::fs::copy(&source_key, seed_dir.join("private.key"))?;
let source_pub = source.join("public.key");
if source_pub.exists() {
std::fs::copy(&source_pub, seed_dir.join("public.key"))?;
}
// load (not load_or_generate): a botched copy must fail
// loudly here, not silently mint a different identity.
SeedId::load(&seed_dir)?
}
_ => anyhow::bail!(
"state your intent: --fresh (new identity) or --migrate-from <dir> \
(carry an existing one over)"
),
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
seed_dir.join("private.key"),
std::fs::Permissions::from_mode(0o600),
)?;
std::fs::set_permissions(&seed_dir, std::fs::Permissions::from_mode(0o700))?;
}
// The daemon runs as the `souveraine` system user; hand the seed
// over if we can (init is normally run via sudo). A missing user
// means the sysusers entry isn't installed yet — say so, don't
// half-succeed silently.
let chown = std::process::Command::new("chown")
.arg("-R")
.arg("souveraine:souveraine")
.arg(&seed_dir)
.status();
let owned = matches!(chown, Ok(s) if s.success());
if !owned {
eprintln!(
"WARNING: could not chown {} to souveraine:souveraine — is the \
`souveraine` system user installed (sysusers.d) and are you root? \
souveraine-machined will refuse to read the seed until ownership \
is fixed.",
seed_dir.display()
);
}
if json {
println!("{}", serde_json::json!({
"public_key": seed.public_key_hex(),
"glyph": seed.glyph(),
"seed_dir": seed_dir.display().to_string(),
"owned_by_service_user": owned,
}));
} else {
println!("Machine identity provisioned.");
println!(" Glyph: {}", seed.glyph());
println!(" Public key: {}", seed.public_key_hex());
println!(" Location: {}", seed_dir.display());
println!("Next: systemctl enable --now souveraine-machined");
}
}
MachineAction::Status => {
match crate::machined::client::status() {
Ok(state) => {
if json {
println!("{}", state);
} else {
println!("souveraine-machined: running");
if let Some(glyph) = state.get("glyph").and_then(|v| v.as_str()) {
println!(" Glyph: {glyph}");
}
if let Some(pk) = state.get("public_key").and_then(|v| v.as_str()) {
println!(" Public key: {pk}");
}
}
}
Err(e) => {
// Daemon down. Report what can be seen from here: a
// permission error on the key is the healthy shape (the
// seed exists and only the service user reads it).
let key = seed_dir.join("private.key");
let seed_state = match std::fs::metadata(&key) {
Ok(_) => "present",
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
"present (unreadable from this user — expected)"
}
Err(_) => "absent",
};
if json {
println!("{}", serde_json::json!({
"ok": false,
"daemon": "unreachable",
"reason": format!("{e:#}"),
"seed": seed_state,
"seed_dir": seed_dir.display().to_string(),
}));
} else {
println!("souveraine-machined: not reachable ({e:#})");
println!(" Seed at {}: {seed_state}", seed_dir.display());
if seed_state == "absent" {
println!(" Provision: sudo souveraine machine init --fresh");
println!(" or: sudo souveraine machine init --migrate-from ~/.souveraine/seed-id");
} else {
println!(" Start: systemctl enable --now souveraine-machined");
}
}
}
}
}
}
Ok(())
}
async fn run_peers(json: bool) -> anyhow::Result<()> {
let base = dirs::home_dir()
.unwrap_or_default()