feat: Bascule — identity-aware SSH proxy

Open-source SSH proxy with pluggable authentication and
extensible session handling. Zero external governance dependencies.

Core (bascule-core):
  russh 0.46 SSH server with PTY bridge (portable-pty)
  Pluggable auth: AuthProvider trait (SSH keys, accept-all dev mode)
  SessionHandler trait for extending behavior (audit, governance)
  TOML configuration, ephemeral Ed25519 host key generation

Binary (bascule-server):
  Single binary, 5.6MB release build
  CLI with --config flag
  Default: accept-all auth on port 2222

Extension points:
  AuthProvider — implement for OIDC, certificates, custom auth
  SessionHandler — implement for audit, governance, recording
  DefaultHandler — passthrough (ships with open-source version)

Zero substrate/chronicle/gsap/hfl dependencies.
Apache 2.0 License.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler King 2026-04-04 22:25:33 -04:00
commit bfa26cfd15
15 changed files with 3515 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target/
*.swp
*.swo
.env

2734
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

25
Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[workspace]
members = ["crates/bascule-core", "crates/bascule-server"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
[workspace.dependencies]
russh = "0.46"
russh-keys = "0.46"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
anyhow = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
rand = "0.8"
clap = { version = "4", features = ["derive"] }
portable-pty = "0.8"

View file

@ -0,0 +1,23 @@
# Bascule SSH Proxy — Example Configuration
# Listen address
listen_addr = "0.0.0.0:2222"
# Host key (auto-generated if not present)
# host_key_path = "/etc/bascule/host_key"
# Shell command to spawn for each session
# Default: /bin/bash
# shell_command = "/bin/bash"
# shell_command = "/usr/local/bin/gsh" # Governed shell
# Authentication
[auth]
mode = "accept-all" # "accept-all" (dev only), "authorized-keys"
# authorized_keys_path = "/etc/bascule/authorized_keys"
# Session banner (optional)
# banner = "Welcome to the governed shell."
# Max concurrent sessions (0 = unlimited)
# max_sessions = 100

View file

@ -0,0 +1,25 @@
[package]
name = "bascule-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Identity-aware SSH proxy — library crate"
[lib]
name = "bascule_core"
path = "src/lib.rs"
[dependencies]
russh = { workspace = true }
russh-keys = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
rand = { workspace = true }
portable-pty = { workspace = true }

View file

@ -0,0 +1,41 @@
//! Pluggable authentication providers.
use async_trait::async_trait;
use russh_keys::key::PublicKey;
/// Authentication provider trait.
#[async_trait]
pub trait AuthProvider: Send + Sync + 'static {
/// Validate SSH public key authentication.
async fn check_public_key(&self, user: &str, key: &PublicKey) -> bool {
let _ = (user, key);
false
}
/// Validate password authentication.
async fn check_password(&self, user: &str, password: &str) -> bool {
let _ = (user, password);
false
}
/// Return the authenticated principal identifier.
fn principal_for_user(&self, user: &str) -> String {
user.to_string()
}
}
/// Accept all SSH keys — development only.
pub struct AcceptAllKeys;
#[async_trait]
impl AuthProvider for AcceptAllKeys {
async fn check_public_key(&self, _user: &str, _key: &PublicKey) -> bool {
tracing::warn!("accept-all: accepting any key (dev mode)");
true
}
async fn check_password(&self, _user: &str, _password: &str) -> bool {
tracing::warn!("accept-all: accepting any password (dev mode)");
true
}
}

View file

@ -0,0 +1,75 @@
//! Configuration — loaded from TOML file.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct BasculeConfig {
/// Address to listen on (default: 0.0.0.0:2222)
#[serde(default = "default_listen")]
pub listen_addr: String,
/// Path to host key (generated if not present)
pub host_key_path: Option<String>,
/// Command to spawn for shell sessions.
/// If not set, uses the user's login shell.
pub shell_command: Option<String>,
/// Arguments for shell_command.
#[serde(default)]
pub shell_args: Vec<String>,
/// Authentication configuration.
#[serde(default)]
pub auth: AuthConfig,
/// Session banner (shown after auth).
pub banner: Option<String>,
/// Maximum concurrent sessions (0 = unlimited).
#[serde(default)]
pub max_sessions: usize,
}
#[derive(Debug, Deserialize, Default)]
pub struct AuthConfig {
/// Auth mode: "accept-all" (dev), "authorized-keys"
#[serde(default = "default_auth_mode")]
pub mode: String,
/// Path to authorized_keys file (for authorized-keys mode).
pub authorized_keys_path: Option<String>,
}
impl Default for BasculeConfig {
fn default() -> Self {
Self {
listen_addr: default_listen(),
host_key_path: None,
shell_command: None,
shell_args: vec![],
auth: AuthConfig::default(),
banner: None,
max_sessions: 0,
}
}
}
impl BasculeConfig {
pub fn from_toml(toml_str: &str) -> anyhow::Result<Self> {
Ok(toml::from_str(toml_str)?)
}
pub fn from_file(path: &str) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_toml(&content)
}
}
fn default_listen() -> String {
"0.0.0.0:2222".to_string()
}
fn default_auth_mode() -> String {
"accept-all".to_string()
}

View file

@ -0,0 +1,247 @@
//! SSH handler — implements russh's Handler trait.
//!
//! Bridges SSH protocol events to PTY sessions with SessionHandler hooks.
use std::io::{Read, Write};
use std::sync::Arc;
use async_trait::async_trait;
use russh::server::{Auth, Handler, Msg, Session};
use russh::{Channel, ChannelId, CryptoVec};
use russh_keys::key;
use tokio::sync::Mutex;
use crate::auth::AuthProvider;
use crate::config::BasculeConfig;
use crate::hooks::SessionHandler;
use crate::pty::{self, PtyBridge};
use crate::session::SessionInfo;
/// Per-connection SSH handler.
pub struct BasculeHandler {
auth: Arc<dyn AuthProvider>,
session_handler: Arc<dyn SessionHandler>,
config: Arc<BasculeConfig>,
session_info: Option<SessionInfo>,
pty_bridge: Option<Arc<Mutex<PtyBridge>>>,
pty_cols: u16,
pty_rows: u16,
peer_addr: String,
}
impl BasculeHandler {
pub fn new(
auth: Arc<dyn AuthProvider>,
session_handler: Arc<dyn SessionHandler>,
config: Arc<BasculeConfig>,
peer_addr: String,
) -> Self {
Self {
auth,
session_handler,
config,
session_info: None,
pty_bridge: None,
pty_cols: 80,
pty_rows: 24,
peer_addr,
}
}
fn shell_command(&self) -> (&str, &[String]) {
match &self.config.shell_command {
Some(cmd) => (cmd.as_str(), self.config.shell_args.as_slice()),
None => ("/bin/bash", &[]),
}
}
/// Spawn PTY, start read loop, return Ok.
async fn spawn_shell(
&mut self,
channel: ChannelId,
session: &mut Session,
command: Option<&str>,
) -> anyhow::Result<()> {
let info = self.session_info.as_ref()
.ok_or_else(|| anyhow::anyhow!("No session info"))?
.clone();
// SessionHandler hook
self.session_handler.on_session_start(&info).await?;
// Build env
let mut env = self.session_handler.build_session_env(&info).await;
env.insert("BASCULE_SESSION_ID".into(), info.session_id.clone());
env.insert("BASCULE_PRINCIPAL".into(), info.principal.clone());
let env_pairs: Vec<(String, String)> = env.into_iter().collect();
// Determine command
let (cmd, args) = match command {
Some(c) => ("/bin/sh", vec!["-c".to_string(), c.to_string()]),
None => {
let (c, a) = self.shell_command();
(c, a.to_vec())
}
};
let bridge = pty::spawn_pty(cmd, &args, env_pairs, self.pty_cols, self.pty_rows)?;
let bridge = Arc::new(Mutex::new(bridge));
self.pty_bridge = Some(bridge.clone());
// Start read loop
let handle = session.handle();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
let n = {
let mut b = bridge.lock().await;
match b.reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => n,
}
};
if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() {
break;
}
}
let _ = handle.close(channel).await;
});
Ok(())
}
}
#[async_trait]
impl Handler for BasculeHandler {
type Error = anyhow::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &key::PublicKey,
) -> Result<Auth, Self::Error> {
if self.auth.check_public_key(user, public_key).await {
let principal = self.auth.principal_for_user(user);
self.session_info = Some(SessionInfo::new(
principal, "ssh-key".into(), self.peer_addr.clone(),
));
Ok(Auth::Accept)
} else {
Ok(Auth::Reject { proceed_with_methods: None })
}
}
async fn auth_password(
&mut self,
user: &str,
password: &str,
) -> Result<Auth, Self::Error> {
if self.auth.check_password(user, password).await {
let principal = self.auth.principal_for_user(user);
self.session_info = Some(SessionInfo::new(
principal, "password".into(), self.peer_addr.clone(),
));
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.pty_cols = col_width.min(500) as u16;
self.pty_rows = row_height.min(200) as u16;
session.request_success();
Ok(())
}
async fn window_change_request(
&mut self,
_channel: ChannelId,
col_width: u32,
row_height: u32,
_pix_width: u32,
_pix_height: u32,
_session: &mut Session,
) -> Result<(), Self::Error> {
self.pty_cols = col_width.min(500) as u16;
self.pty_rows = row_height.min(200) as u16;
if let Some(bridge) = &self.pty_bridge {
let b = bridge.lock().await;
let _ = b.resize(self.pty_cols, self.pty_rows);
}
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
// Send banner
if let Some(info) = &self.session_info {
let display = self.session_handler.display_name(info);
let banner = self.config.banner.as_deref()
.map(|b| format!("{}\r\n", b))
.unwrap_or_else(|| format!("Welcome, {}.\r\n", display));
session.data(channel, CryptoVec::from_slice(banner.as_bytes()));
}
session.request_success();
self.spawn_shell(channel, session, None).await?;
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
let command = String::from_utf8_lossy(data).to_string();
if let Some(info) = &self.session_info {
if let Err(e) = self.session_handler.on_exec(info, &command).await {
let msg = format!("Denied: {}\r\n", e);
session.data(channel, CryptoVec::from_slice(msg.as_bytes()));
session.close(channel);
return Ok(()); // close returns ()
}
}
session.request_success();
self.spawn_shell(channel, session, Some(&command)).await?;
Ok(())
}
async fn data(
&mut self,
_channel: ChannelId,
data: &[u8],
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(bridge) = &self.pty_bridge {
let mut b = bridge.lock().await;
let _ = b.writer.write_all(data);
let _ = b.writer.flush();
}
Ok(())
}
}

View file

@ -0,0 +1,56 @@
//! SessionHandler trait — the extension point for governance, audit, and custom logic.
//!
//! Implement this trait to add behavior to Bascule sessions without modifying
//! the SSH proxy core. The default implementation accepts everything and adds nothing.
use async_trait::async_trait;
use std::collections::HashMap;
use crate::session::SessionInfo;
/// Hook point for extending Bascule's behavior.
///
/// The open-source version ships with [`DefaultHandler`] (passthrough).
/// Governance extensions implement this trait to add authorization contexts,
/// completion receipts, operational posture checks, and audit trails.
#[async_trait]
pub trait SessionHandler: Send + Sync + 'static {
/// Called after SSH authentication succeeds, before shell spawn.
/// Return Err to reject the session.
async fn on_session_start(&self, session: &SessionInfo) -> anyhow::Result<()> {
let _ = session;
Ok(())
}
/// Build additional environment variables for the spawned shell.
/// Merged with the standard session env vars.
async fn build_session_env(&self, session: &SessionInfo) -> HashMap<String, String> {
let _ = session;
HashMap::new()
}
/// Called when a command is executed via `exec` (non-interactive).
/// Return Err to deny the command.
async fn on_exec(&self, session: &SessionInfo, command: &str) -> anyhow::Result<()> {
let _ = (session, command);
Ok(())
}
/// Called when the session ends (disconnect or exit).
async fn on_session_end(&self, session: &SessionInfo) -> anyhow::Result<()> {
let _ = session;
Ok(())
}
/// Return a display name for the session banner.
fn display_name(&self, session: &SessionInfo) -> String {
session.principal.clone()
}
}
/// Default handler — no governance, pure SSH proxy.
/// Accepts all sessions, adds no environment, logs nothing.
pub struct DefaultHandler;
#[async_trait]
impl SessionHandler for DefaultHandler {}

View file

@ -0,0 +1,18 @@
//! Bascule Core — identity-aware SSH proxy library.
//!
//! Provides a pluggable SSH server with:
//! - Configurable authentication (SSH keys, OIDC, accept-all)
//! - PTY bridging to any shell command
//! - SessionHandler trait for extending behavior (audit, governance, recording)
//!
//! The open-source core ships with `DefaultHandler` (passthrough).
//! Governance extensions (ACs, CRs, DEFCON, Chronicle) implement
//! `SessionHandler` in separate crates.
pub mod auth;
pub mod config;
pub mod handler;
pub mod hooks;
pub mod pty;
pub mod server;
pub mod session;

View file

@ -0,0 +1,80 @@
//! PTY bridge — spawns a shell and bridges I/O to the SSH channel.
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::{Read, Write};
use tokio::sync::mpsc;
/// Spawn a PTY with the given command and bridge I/O.
///
/// Returns (writer_tx, reader_rx) channels for the SSH handler to use.
pub fn spawn_pty(
command: &str,
args: &[String],
env: Vec<(String, String)>,
cols: u16,
rows: u16,
) -> Result<PtyBridge> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("Failed to open PTY")?;
let mut cmd = CommandBuilder::new(command);
for arg in args {
cmd.arg(arg);
}
for (key, val) in &env {
cmd.env(key, val);
}
let child = pair
.slave
.spawn_command(cmd)
.context("Failed to spawn shell")?;
let reader = pair
.master
.try_clone_reader()
.context("Failed to clone PTY reader")?;
let writer = pair
.master
.take_writer()
.context("Failed to take PTY writer")?;
Ok(PtyBridge {
master: pair.master,
child,
reader,
writer,
})
}
/// A running PTY session with I/O handles.
pub struct PtyBridge {
pub master: Box<dyn portable_pty::MasterPty + Send>,
pub child: Box<dyn portable_pty::Child + Send + Sync>,
pub reader: Box<dyn Read + Send>,
pub writer: Box<dyn Write + Send>,
}
impl PtyBridge {
/// Resize the PTY.
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("Failed to resize PTY")?;
Ok(())
}
}

View file

@ -0,0 +1,75 @@
//! SSH server — listens for connections and dispatches to handlers.
use std::sync::Arc;
use russh::server::{Config, Server};
use russh_keys::key::KeyPair;
use crate::auth::AuthProvider;
use crate::config::BasculeConfig;
use crate::handler::BasculeHandler;
use crate::hooks::SessionHandler;
/// The Bascule SSH server.
pub struct BasculeServer {
ssh_config: Arc<Config>,
app_config: Arc<BasculeConfig>,
auth: Arc<dyn AuthProvider>,
session_handler: Arc<dyn SessionHandler>,
}
impl BasculeServer {
pub fn new(
config: BasculeConfig,
auth: impl AuthProvider + 'static,
session_handler: impl SessionHandler + 'static,
) -> anyhow::Result<Self> {
let host_key = if let Some(path) = &config.host_key_path {
if std::path::Path::new(path).exists() {
tracing::info!(path = %path, "Loading host key");
russh_keys::load_secret_key(path, None)?
} else {
tracing::info!("Generating ephemeral Ed25519 host key");
KeyPair::generate_ed25519()
}
} else {
tracing::info!("No host key configured, generating ephemeral key");
KeyPair::generate_ed25519()
};
let ssh_config = Config {
keys: vec![host_key],
..Default::default()
};
Ok(Self {
ssh_config: Arc::new(ssh_config),
app_config: Arc::new(config),
auth: Arc::new(auth),
session_handler: Arc::new(session_handler),
})
}
pub async fn run(mut self) -> anyhow::Result<()> {
let addr = &self.app_config.listen_addr;
tracing::info!(addr = %addr, "Starting Bascule SSH server");
let socket_addr: std::net::SocketAddr = addr.parse()?;
self.run_on_address(self.ssh_config.clone(), socket_addr).await?;
Ok(())
}
}
impl russh::server::Server for BasculeServer {
type Handler = BasculeHandler;
fn new_client(&mut self, peer_addr: Option<std::net::SocketAddr>) -> BasculeHandler {
let addr = peer_addr.map(|a| a.to_string()).unwrap_or_else(|| "unknown".to_string());
tracing::info!(peer = %addr, "New SSH connection");
BasculeHandler::new(
self.auth.clone(),
self.session_handler.clone(),
self.app_config.clone(),
addr,
)
}
}

View file

@ -0,0 +1,30 @@
//! Session state and lifecycle.
use chrono::{DateTime, Utc};
/// Information about an authenticated SSH session.
#[derive(Debug, Clone)]
pub struct SessionInfo {
/// Unique session identifier.
pub session_id: String,
/// Authenticated principal (username, email, or DID).
pub principal: String,
/// How the user authenticated: "ssh-key", "password", "oidc".
pub auth_method: String,
/// Client IP address.
pub source_ip: String,
/// When the session was established.
pub connected_at: DateTime<Utc>,
}
impl SessionInfo {
pub fn new(principal: String, auth_method: String, source_ip: String) -> Self {
Self {
session_id: uuid::Uuid::new_v4().to_string(),
principal,
auth_method,
source_ip,
connected_at: Utc::now(),
}
}
}

View file

@ -0,0 +1,18 @@
[package]
name = "bascule-server"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Bascule — identity-aware SSH proxy"
[[bin]]
name = "bascule"
path = "src/main.rs"
[dependencies]
bascule-core = { path = "../bascule-core" }
tokio = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }

View file

@ -0,0 +1,64 @@
//! Bascule — identity-aware SSH proxy.
//!
//! Usage:
//! bascule --config /etc/bascule/config.toml
//! bascule # uses default config (accept-all auth, port 2222)
use anyhow::Result;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use bascule_core::auth::AcceptAllKeys;
use bascule_core::config::BasculeConfig;
use bascule_core::hooks::DefaultHandler;
use bascule_core::server::BasculeServer;
#[derive(Parser)]
#[command(name = "bascule", about = "Identity-aware SSH proxy")]
struct Cli {
/// Path to configuration file (TOML)
#[arg(short, long)]
config: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let config = match &cli.config {
Some(path) => {
tracing::info!(path = %path, "Loading configuration");
BasculeConfig::from_file(path)?
}
None => {
tracing::info!("No config file specified, using defaults (accept-all auth, port 2222)");
BasculeConfig::default()
}
};
tracing::info!(
listen = %config.listen_addr,
auth = %config.auth.mode,
shell = ?config.shell_command,
"Bascule starting"
);
// Select auth provider based on config
let auth_mode = config.auth.mode.as_str();
match auth_mode {
_ => {
// accept-all (default for dev)
if auth_mode != "accept-all" {
tracing::warn!(mode = %auth_mode, "Unknown auth mode, falling back to accept-all");
}
let server = BasculeServer::new(config, AcceptAllKeys, DefaultHandler)?;
server.run().await
}
}
}