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:
commit
bfa26cfd15
15 changed files with 3515 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
2734
Cargo.lock
generated
Normal file
2734
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal 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"
|
||||||
23
config/bascule.example.toml
Normal file
23
config/bascule.example.toml
Normal 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
|
||||||
25
crates/bascule-core/Cargo.toml
Normal file
25
crates/bascule-core/Cargo.toml
Normal 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 }
|
||||||
41
crates/bascule-core/src/auth.rs
Normal file
41
crates/bascule-core/src/auth.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/bascule-core/src/config.rs
Normal file
75
crates/bascule-core/src/config.rs
Normal 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()
|
||||||
|
}
|
||||||
247
crates/bascule-core/src/handler.rs
Normal file
247
crates/bascule-core/src/handler.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
56
crates/bascule-core/src/hooks.rs
Normal file
56
crates/bascule-core/src/hooks.rs
Normal 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 {}
|
||||||
18
crates/bascule-core/src/lib.rs
Normal file
18
crates/bascule-core/src/lib.rs
Normal 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;
|
||||||
80
crates/bascule-core/src/pty.rs
Normal file
80
crates/bascule-core/src/pty.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/bascule-core/src/server.rs
Normal file
75
crates/bascule-core/src/server.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/bascule-core/src/session.rs
Normal file
30
crates/bascule-core/src/session.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/bascule-server/Cargo.toml
Normal file
18
crates/bascule-server/Cargo.toml
Normal 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 }
|
||||||
64
crates/bascule-server/src/main.rs
Normal file
64
crates/bascule-server/src/main.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue