bascule-workspace/bascule-agent/src/ssh_server.rs
Tyler King b1865a0627 initial: bascule v0.1.0
Bascule shell runtime workspace — governed shell access layer
for Substrate/Guildhouse FFC deployments.

Crates:
- bascule-agent: node agent with SSH server + command filtering
- bascule-core: audit, grant engine, ceremony types, session
- bascule-filter-core: log line filtering (stdio protocol)
- bascule-gateway: OIDC auth, session management, SAT validation
- bascule-node-agent: k8s DaemonSet agent (pod watcher, BPF manager)
- bascule-proto: protobuf definitions
- bascule-shell: governed SSH shell (commands, elevation, REPL)
- bascule-tail: chronicle log tail + fanout
- ceremony-engine: ceremony lifecycle (6 types + request/resolution)

172 tests passing.
Implements SBS-SPEC-0001 shell model.
Reference impl for SPEC-SHELLOPS-0001 Layer 1 (root shell).
2026-03-18 16:40:48 -04:00

511 lines
17 KiB
Rust

//! SSH shell server — interactive governed shell sessions.
//!
//! Provides an SSH server that routes commands through the namespace
//! handlers. In dev/soft mode, accepts any public key authentication.
use std::sync::Arc;
use async_trait::async_trait;
use russh::server::{Auth, Config, Handler, Msg, Server, Session};
use russh::{Channel, ChannelId};
use ssh_key::{Algorithm, PrivateKey};
use tracing::{debug, error, info, warn};
use crate::config::AgentConfig;
use crate::namespace::NamespaceRouter;
use crate::shellstream::{Namespace, ShellstreamResponse, Status};
/// The bascule-agent SSH server.
pub struct AgentSSHServer {
config: Arc<AgentConfig>,
russh_config: Arc<Config>,
router: Arc<NamespaceRouter>,
}
impl AgentSSHServer {
pub fn new(
config: Arc<AgentConfig>,
router: Arc<NamespaceRouter>,
) -> anyhow::Result<Self> {
// Load or generate host key
let host_key = if let Some(ref path) = config.agent.ssh.host_key_path {
if path.exists() {
info!(path = %path.display(), "Loading SSH host key");
russh_keys::load_secret_key(path, None)?
} else {
info!("Generating ephemeral Ed25519 host key");
let mut rng = rand::thread_rng();
PrivateKey::random(&mut rng, Algorithm::Ed25519)?
}
} else {
info!("Generating ephemeral Ed25519 host key");
let mut rng = rand::thread_rng();
PrivateKey::random(&mut rng, Algorithm::Ed25519)?
};
let russh_config = Config {
keys: vec![host_key],
..Default::default()
};
Ok(Self {
config,
russh_config: Arc::new(russh_config),
router,
})
}
/// Start the SSH server. Runs until cancelled.
pub async fn serve(mut self) -> anyhow::Result<()> {
let addr = &self.config.agent.ssh.listen_addr;
let listen: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("Invalid SSH listen address '{}': {}", addr, e))?;
info!(addr = %listen, "SSH shell server listening");
self.run_on_address(self.russh_config.clone(), listen).await?;
Ok(())
}
}
impl Server for AgentSSHServer {
type Handler = AgentShellHandler;
fn new_client(&mut self, peer_addr: Option<std::net::SocketAddr>) -> AgentShellHandler {
let addr = peer_addr
.map(|a| a.to_string())
.unwrap_or_else(|| "unknown".to_string());
info!(peer = %addr, "New SSH connection");
AgentShellHandler {
router: self.router.clone(),
dev_mode: self.config.agent.namespaces.backend == "soft",
peer_addr: addr,
identity: None,
terminal_width: 80,
session_state: None,
}
}
}
/// Per-connection SSH handler.
pub struct AgentShellHandler {
router: Arc<NamespaceRouter>,
dev_mode: bool,
peer_addr: String,
identity: Option<String>,
terminal_width: u16,
session_state: Option<ShellSessionState>,
}
struct ShellSessionState {
input_buffer: String,
channel_id: ChannelId,
session_id: [u8; 16],
}
impl AgentShellHandler {
fn banner(&self) -> String {
let identity = self.identity.as_deref().unwrap_or("unknown");
format!(
"\r\n\x1b[1;36m╔══════════════════════════════════════════╗\r\n\
║ bascule-agent governed shell ║\r\n\
╚══════════════════════════════════════════╝\x1b[0m\r\n\
\r\n\
Identity: {identity}\r\n\
Mode: {mode}\r\n\
\r\n\
Type 'help' for available commands.\r\n\r\n",
mode = if self.dev_mode { "dev (soft)" } else { "live" },
)
}
fn prompt(&self) -> String {
let identity = self.identity.as_deref().unwrap_or("?");
format!("\x1b[1;32m{identity}\x1b[0m@\x1b[1;34magent\x1b[0m> ")
}
async fn process_line(
&self,
line: &str,
handle: &russh::server::Handle,
channel_id: ChannelId,
session_id: &[u8; 16],
) -> bool {
let line = line.trim();
if line == "exit" || line == "quit" {
let _ = handle.data(channel_id, "Goodbye.\r\n".into()).await;
let _ = handle.close(channel_id).await;
return true;
}
if line.is_empty() {
let _ = handle.data(channel_id, self.prompt().into()).await;
return false;
}
let output = match line {
"help" => self.cmd_help(),
"status" => self.cmd_status().await,
"whoami" => self.cmd_whoami(),
cmd if cmd.starts_with("audit ") => {
self.cmd_namespace("audit", &cmd[6..], session_id).await
}
cmd if cmd.starts_with("crypto ") => {
self.cmd_namespace("crypto", &cmd[7..], session_id).await
}
cmd if cmd.starts_with("identity ") => {
self.cmd_namespace("identity", &cmd[9..], session_id).await
}
cmd if cmd.starts_with("governance ") => {
self.cmd_namespace("governance", &cmd[11..], session_id).await
}
cmd if cmd.starts_with("secrets ") => {
self.cmd_namespace("secrets", &cmd[8..], session_id).await
}
cmd if cmd.starts_with("attestation ") => {
self.cmd_namespace("attestation", &cmd[12..], session_id).await
}
_ => format!("Unknown command: {}. Type 'help' for available commands.\r\n", line),
};
if !output.is_empty() {
let _ = handle.data(channel_id, output.into()).await;
}
let _ = handle.data(channel_id, self.prompt().into()).await;
false
}
fn cmd_help(&self) -> String {
"\x1b[1mAvailable commands:\x1b[0m\r\n\
\r\n\
\x1b[33mShell:\x1b[0m\r\n\
\x20 help Show this help message\r\n\
\x20 status Show agent status\r\n\
\x20 whoami Show current identity\r\n\
\x20 exit Close the session\r\n\
\r\n\
\x1b[33mNamespaces:\x1b[0m\r\n\
\x20 audit emit Emit an audit event\r\n\
\x20 attestation posture Show posture level\r\n\
\x20 crypto hash <data> Hash data with SHA-256\r\n\
\x20 governance gate Check governance gate\r\n\
\x20 identity whoami Show identity claims\r\n\
\x20 secrets get <path> Look up a secret\r\n\
\r\n"
.to_string()
}
async fn cmd_status(&self) -> String {
// Query attestation posture through namespace handler
let payload = rmp_serde::to_vec(&serde_json::json!({})).unwrap_or_default();
let session_id = [0u8; 16];
let resp = self
.router
.handle(Namespace::Attestation, 0x01, &payload, &session_id)
.await;
let posture: serde_json::Value =
rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null);
format!(
"\x1b[1mAgent Status\x1b[0m\r\n\
\x20 Mode: {mode}\r\n\
\x20 Posture: {posture}\r\n\
\x20 Peer: {peer}\r\n\r\n",
mode = if self.dev_mode { "soft (dev)" } else { "live" },
posture = posture.get("level").and_then(|v| v.as_str()).unwrap_or("unknown"),
peer = self.peer_addr,
)
}
fn cmd_whoami(&self) -> String {
format!(
"Identity: {}\r\nMode: {}\r\nPeer: {}\r\n",
self.identity.as_deref().unwrap_or("unknown"),
if self.dev_mode { "dev" } else { "live" },
self.peer_addr,
)
}
async fn cmd_namespace(
&self,
ns_name: &str,
args: &str,
session_id: &[u8; 16],
) -> String {
let ns = match ns_name {
"audit" => Namespace::Audit,
"crypto" => Namespace::Crypto,
"identity" => Namespace::Identity,
"governance" => Namespace::Governance,
"secrets" => Namespace::Secrets,
"attestation" => Namespace::Attestation,
_ => return format!("Unknown namespace: {ns_name}\r\n"),
};
let (function_id, payload) = match parse_namespace_command(ns_name, args) {
Ok(v) => v,
Err(e) => return format!("Error: {e}\r\n"),
};
let resp = self.router.handle(ns, function_id, &payload, session_id).await;
let status = Status::from_u16(resp.status);
let payload_parsed: serde_json::Value =
rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null);
let status_str = match status {
Some(Status::Ok) => "\x1b[32mOK\x1b[0m",
Some(Status::Error) => "\x1b[31mERROR\x1b[0m",
Some(Status::Denied) => "\x1b[31mDENIED\x1b[0m",
Some(Status::CeremonyRequired) => "\x1b[33mCEREMONY_REQUIRED\x1b[0m",
None => "UNKNOWN",
};
format!(
"[{status_str}] {}\r\n",
serde_json::to_string_pretty(&payload_parsed).unwrap_or_else(|_| "{}".to_string())
)
.replace('\n', "\r\n")
}
}
/// Parse a namespace subcommand into (function_id, msgpack payload).
fn parse_namespace_command(ns: &str, args: &str) -> anyhow::Result<(u16, Vec<u8>)> {
let parts: Vec<&str> = args.splitn(2, ' ').collect();
let subcmd = parts.first().copied().unwrap_or("");
let rest = parts.get(1).copied().unwrap_or("");
match (ns, subcmd) {
("audit", "emit") => {
let payload = serde_json::json!({
"event_type": "shell.command",
"subject": "ssh-user",
"resource": "shell",
"action": "emit",
"outcome": "success",
});
Ok((0x01, rmp_serde::to_vec(&payload)?))
}
("audit", "anchor") => {
Ok((0x02, rmp_serde::to_vec(&serde_json::json!({"event_ids": []}))?))
}
("crypto", "hash") => {
let data = if rest.is_empty() { "test" } else { rest };
Ok((0x03, rmp_serde::to_vec(&serde_json::json!({"data": data}))?))
}
("identity", "whoami") | ("identity", "authenticate") => {
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"token": "ssh-session"}))?))
}
("governance", "gate") => {
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({
"subject": "ssh-user",
"resource": "shell",
"action": "access",
}))?))
}
("secrets", "get") => {
let path = if rest.is_empty() { "test/secret" } else { rest };
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"path": path}))?))
}
("attestation", "posture") => {
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({}))?))
}
("attestation", "sat") => {
Ok((0x02, rmp_serde::to_vec(&serde_json::json!({}))?))
}
_ => anyhow::bail!("Unknown {ns} subcommand: {subcmd}"),
}
}
#[async_trait]
impl Handler for AgentShellHandler {
type Error = anyhow::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
if self.dev_mode {
// Dev mode: accept any key
info!(user = %user, fingerprint = %fingerprint, "SSH auth accepted (dev mode)");
self.identity = Some(user.to_string());
Ok(Auth::Accept)
} else {
// TODO: validate against authorized keys store
warn!(fingerprint = %fingerprint, "SSH auth rejected: live mode not implemented");
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
async fn auth_password(
&mut self,
user: &str,
_password: &str,
) -> Result<Auth, Self::Error> {
if self.dev_mode {
info!(user = %user, "SSH password auth accepted (dev mode)");
self.identity = Some(user.to_string());
Ok(Auth::Accept)
} else {
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
async fn channel_open_session(
&mut self,
_channel: Channel<Msg>,
_session: &mut Session,
) -> Result<bool, Self::Error> {
Ok(true)
}
async fn pty_request(
&mut self,
_channel: ChannelId,
_term: &str,
col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
self.terminal_width = col_width.min(200) as u16;
session.request_success();
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
session.request_success();
let session_id: [u8; 16] = uuid::Uuid::new_v4().into_bytes();
let banner = self.banner();
let prompt = self.prompt();
session.data(channel, format!("{banner}{prompt}").into())?;
self.session_state = Some(ShellSessionState {
input_buffer: String::new(),
channel_id: channel,
session_id,
});
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
// Take session state temporarily to avoid borrow conflicts
let mut state = match self.session_state.take() {
Some(s) => s,
None => return Ok(()),
};
for &byte in data {
match byte {
// Enter
b'\r' | b'\n' => {
session.data(channel, "\r\n".into())?;
let line = state.input_buffer.clone();
state.input_buffer.clear();
let handle = session.handle();
let sid = state.session_id;
if self.process_line(&line, &handle, channel, &sid).await {
// Session ended — don't put state back
return Ok(());
}
}
// Backspace / DEL
0x7f | 0x08 => {
if !state.input_buffer.is_empty() {
state.input_buffer.pop();
session.data(channel, "\x08 \x08".into())?;
}
}
// Ctrl-C
0x03 => {
state.input_buffer.clear();
session.data(channel, "^C\r\n".into())?;
let prompt = self.prompt();
session.data(channel, prompt.into())?;
}
// Ctrl-D (EOF)
0x04 => {
session.data(channel, "\r\nGoodbye.\r\n".into())?;
session.close(channel)?;
return Ok(());
}
// Printable characters
b if b >= 0x20 && b < 0x7f => {
state.input_buffer.push(b as char);
session.data(channel, vec![b].into())?;
}
// Ignore other control characters
_ => {}
}
}
// Put session state back
self.session_state = Some(state);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_namespace_commands() {
let (fn_id, _) = parse_namespace_command("audit", "emit").unwrap();
assert_eq!(fn_id, 0x01);
let (fn_id, _) = parse_namespace_command("crypto", "hash test data").unwrap();
assert_eq!(fn_id, 0x03);
let (fn_id, _) = parse_namespace_command("governance", "gate").unwrap();
assert_eq!(fn_id, 0x01);
let (fn_id, _) = parse_namespace_command("attestation", "posture").unwrap();
assert_eq!(fn_id, 0x01);
let (fn_id, _) = parse_namespace_command("secrets", "get db/password").unwrap();
assert_eq!(fn_id, 0x01);
assert!(parse_namespace_command("audit", "nonexistent").is_err());
}
#[test]
fn test_parse_crypto_hash_payload() {
let (_, payload) = parse_namespace_command("crypto", "hash hello world").unwrap();
let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap();
assert_eq!(parsed["data"], "hello world");
}
#[test]
fn test_parse_secrets_get_path() {
let (_, payload) = parse_namespace_command("secrets", "get db/password").unwrap();
let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap();
assert_eq!(parsed["path"], "db/password");
}
}