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