feat: structured logging, tracing spans, comprehensive documentation
Observability: Structured JSON logging via BASCULE_LOG_FORMAT=json Tracing spans on auth (method, principal, peer) Tracing spans on session lifecycle (id, principal, backend, source_ip) Tracing spans on exec requests (session_id, command) Config: [telemetry] and [metrics] sections (OTel export planned) Documentation (8 files, 489 lines): docs/quickstart.md — three-path getting started docs/configuration.md — full config reference with examples docs/authentication.md — all auth modes with setup guides docs/architecture.md — backends, traits, extension model, security docs/observability.md — logging, tracing, metrics docs/comparison.md — vs Teleport, Boundary, StrongDM images/README.md — curated image catalog README.md — features, comparison, quickstart, extension example 1557 lines Rust, 489 lines docs, 0 substrate deps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8d789524e8
commit
e7fc9fa5e1
13 changed files with 697 additions and 27 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -193,6 +193,7 @@ name = "bascule-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"bascule-auth-agent-id",
|
||||||
"bascule-core",
|
"bascule-core",
|
||||||
"clap",
|
"clap",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -2898,6 +2899,16 @@ dependencies = [
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-serde"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
|
|
@ -2908,12 +2919,15 @@ dependencies = [
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
|
"tracing-serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
99
README.md
Normal file
99
README.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Bascule
|
||||||
|
|
||||||
|
Identity-aware SSH proxy for modern infrastructure.
|
||||||
|
|
||||||
|
**Bascule** is a lightweight SSH proxy that authenticates users via SSH keys, OIDC, or AI agent tokens, then connects them to a local shell, remote host, or ephemeral container. No agents. No control plane. One binary.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release -p bascule-server
|
||||||
|
./target/release/bascule --config config/bascule.example.toml
|
||||||
|
# In another terminal:
|
||||||
|
ssh -p 2222 localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/quickstart.md](docs/quickstart.md) for Docker and container mode.
|
||||||
|
|
||||||
|
## Session Modes
|
||||||
|
|
||||||
|
| Mode | Config | Use case |
|
||||||
|
|------|--------|----------|
|
||||||
|
| **Local PTY** | (default) | Spawn a local shell |
|
||||||
|
| **Remote proxy** | `[proxy]` | Forward to a remote SSH host |
|
||||||
|
| **Container** | `[container]` | Ephemeral container per session |
|
||||||
|
|
||||||
|
### Container mode
|
||||||
|
|
||||||
|
Each SSH session spawns a fresh container. The image defines the toolset — if it's not in the image, the operator can't use it.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[container]
|
||||||
|
image = "bascule-shell:k8s-ops"
|
||||||
|
ephemeral = true
|
||||||
|
hardened = true
|
||||||
|
memory_limit = "512m"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Three backends** — local PTY, remote SSH proxy, ephemeral containers
|
||||||
|
- **Identity-aware sessions** — every connection authenticated and attributed
|
||||||
|
- **SSH key authentication** — standard authorized_keys, no surprises
|
||||||
|
- **AI agent authentication** — native Microsoft Entra Agent ID support
|
||||||
|
- **Right-sized images** — curated container images (minimal, k8s-ops, net-ops, dev)
|
||||||
|
- **SessionHandler trait** — extend with custom policy, audit, or recording
|
||||||
|
- **Structured logging** — JSON format for production observability
|
||||||
|
- **Small footprint** — single binary, ~7MB, <64MB memory
|
||||||
|
|
||||||
|
## Comparison
|
||||||
|
|
||||||
|
| | Bascule | Teleport | Boundary |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Agents | None | Required | Required |
|
||||||
|
| Control plane | None | Required | Required |
|
||||||
|
| License | Apache 2.0 | AGPL | MPL |
|
||||||
|
| Container sessions | Native | No | No |
|
||||||
|
| AI Agent Identity | Native | No | No |
|
||||||
|
| Binary size | ~7MB | ~150MB | ~100MB |
|
||||||
|
|
||||||
|
See [docs/comparison.md](docs/comparison.md) for the full comparison.
|
||||||
|
|
||||||
|
## Extending Bascule
|
||||||
|
|
||||||
|
Implement the `SessionHandler` trait to add custom behavior:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use bascule_core::hooks::{SessionHandler, SessionInfo};
|
||||||
|
|
||||||
|
struct MyAuditHandler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SessionHandler for MyAuditHandler {
|
||||||
|
async fn on_session_start(&self, session: &SessionInfo) -> anyhow::Result<()> {
|
||||||
|
log::info!("{} connected from {}", session.principal, session.source_ip);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_exec(&self, session: &SessionInfo, command: &str) -> anyhow::Result<()> {
|
||||||
|
log::info!("{} executed: {}", session.principal, command);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait to add authorization contexts, completion receipts, and merkle-anchored audit trails.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Quick Start](docs/quickstart.md)
|
||||||
|
- [Configuration](docs/configuration.md)
|
||||||
|
- [Authentication](docs/authentication.md)
|
||||||
|
- [Architecture](docs/architecture.md)
|
||||||
|
- [Observability](docs/observability.md)
|
||||||
|
- [Comparison](docs/comparison.md)
|
||||||
|
- [Container Images](images/README.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache 2.0
|
||||||
|
|
@ -39,6 +39,14 @@ pub struct BasculeConfig {
|
||||||
/// When set, sessions spawn an ephemeral container per connection.
|
/// When set, sessions spawn an ephemeral container per connection.
|
||||||
/// Priority: proxy > container > local PTY.
|
/// Priority: proxy > container > local PTY.
|
||||||
pub container: Option<ContainerConfig>,
|
pub container: Option<ContainerConfig>,
|
||||||
|
|
||||||
|
/// Telemetry (OTel tracing).
|
||||||
|
#[serde(default)]
|
||||||
|
pub telemetry: TelemetryConfig,
|
||||||
|
|
||||||
|
/// Prometheus metrics.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics: MetricsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
|
@ -96,6 +104,8 @@ impl Default for BasculeConfig {
|
||||||
max_sessions: 0,
|
max_sessions: 0,
|
||||||
proxy: None,
|
proxy: None,
|
||||||
container: None,
|
container: None,
|
||||||
|
telemetry: TelemetryConfig::default(),
|
||||||
|
metrics: MetricsConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +179,30 @@ pub struct MountConfig {
|
||||||
pub readonly: bool,
|
pub readonly: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Telemetry configuration (OTel + metrics).
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
pub struct TelemetryConfig {
|
||||||
|
/// OTLP endpoint for trace export.
|
||||||
|
pub otlp_endpoint: Option<String>,
|
||||||
|
/// Service name for OTel spans.
|
||||||
|
#[serde(default = "default_service_name")]
|
||||||
|
pub service_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prometheus metrics endpoint configuration.
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
pub struct MetricsConfig {
|
||||||
|
/// Enable metrics endpoint.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Port for /metrics endpoint.
|
||||||
|
#[serde(default = "default_metrics_port")]
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_service_name() -> String { "bascule".to_string() }
|
||||||
|
fn default_metrics_port() -> u16 { 9090 }
|
||||||
|
|
||||||
fn default_runtime() -> String { "auto".to_string() }
|
fn default_runtime() -> String { "auto".to_string() }
|
||||||
fn default_pull_policy() -> String { "if-not-present".to_string() }
|
fn default_pull_policy() -> String { "if-not-present".to_string() }
|
||||||
fn default_true() -> bool { true }
|
fn default_true() -> bool { true }
|
||||||
|
|
|
||||||
|
|
@ -236,9 +236,11 @@ impl Handler for BasculeHandler {
|
||||||
async fn auth_publickey(&mut self, user: &str, public_key: &key::PublicKey) -> Result<Auth, Self::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 {
|
if self.auth.check_public_key(user, public_key).await {
|
||||||
let principal = self.auth.principal_for_user(user);
|
let principal = self.auth.principal_for_user(user);
|
||||||
|
tracing::info!(method = "ssh-key", principal = %principal, peer = %self.peer_addr, "Auth accepted");
|
||||||
self.session_info = Some(SessionInfo::new(principal, "ssh-key".into(), self.peer_addr.clone()));
|
self.session_info = Some(SessionInfo::new(principal, "ssh-key".into(), self.peer_addr.clone()));
|
||||||
Ok(Auth::Accept)
|
Ok(Auth::Accept)
|
||||||
} else {
|
} else {
|
||||||
|
tracing::warn!(method = "ssh-key", user = %user, peer = %self.peer_addr, "Auth rejected");
|
||||||
Ok(Auth::Reject { proceed_with_methods: None })
|
Ok(Auth::Reject { proceed_with_methods: None })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,9 +248,11 @@ impl Handler for BasculeHandler {
|
||||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||||
if self.auth.check_password(user, password).await {
|
if self.auth.check_password(user, password).await {
|
||||||
let principal = self.auth.principal_for_user(user);
|
let principal = self.auth.principal_for_user(user);
|
||||||
|
tracing::info!(method = "password", principal = %principal, peer = %self.peer_addr, "Auth accepted");
|
||||||
self.session_info = Some(SessionInfo::new(principal, "password".into(), self.peer_addr.clone()));
|
self.session_info = Some(SessionInfo::new(principal, "password".into(), self.peer_addr.clone()));
|
||||||
Ok(Auth::Accept)
|
Ok(Auth::Accept)
|
||||||
} else {
|
} else {
|
||||||
|
tracing::warn!(method = "password", user = %user, peer = %self.peer_addr, "Auth rejected");
|
||||||
Ok(Auth::Reject { proceed_with_methods: None })
|
Ok(Auth::Reject { proceed_with_methods: None })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,6 +281,19 @@ impl Handler for BasculeHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
|
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
|
||||||
|
let backend_type = if self.config.proxy.is_some() { "proxy" }
|
||||||
|
else if self.config.container.is_some() { "container" }
|
||||||
|
else { "pty" };
|
||||||
|
let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown");
|
||||||
|
let principal = self.session_info.as_ref().map(|i| i.principal.as_str()).unwrap_or("unknown");
|
||||||
|
tracing::info!(
|
||||||
|
session.id = %session_id,
|
||||||
|
session.principal = %principal,
|
||||||
|
session.backend = %backend_type,
|
||||||
|
session.source_ip = %self.peer_addr,
|
||||||
|
"Shell session starting"
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(info) = &self.session_info {
|
if let Some(info) = &self.session_info {
|
||||||
let display = self.session_handler.display_name(info);
|
let display = self.session_handler.display_name(info);
|
||||||
let banner = self.config.banner.as_deref()
|
let banner = self.config.banner.as_deref()
|
||||||
|
|
@ -291,6 +308,9 @@ impl Handler for BasculeHandler {
|
||||||
|
|
||||||
async fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
|
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();
|
let command = String::from_utf8_lossy(data).to_string();
|
||||||
|
let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown");
|
||||||
|
tracing::info!(session.id = %session_id, command = %command, "Exec request");
|
||||||
|
|
||||||
if let Some(info) = &self.session_info {
|
if let Some(info) = &self.session_info {
|
||||||
if let Err(e) = self.session_handler.on_exec(info, &command).await {
|
if let Err(e) = self.session_handler.on_exec(info, &command).await {
|
||||||
let msg = format!("Denied: {}\r\n", e);
|
let msg = format!("Denied: {}\r\n", e);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,19 @@ description = "Bascule — identity-aware SSH proxy"
|
||||||
name = "bascule"
|
name = "bascule"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
agent-id = ["dep:bascule-auth-agent-id"]
|
||||||
|
# telemetry = [] — OTel export deferred (version compatibility WIP)
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bascule-core = { path = "../bascule-core" }
|
bascule-core = { path = "../bascule-core" }
|
||||||
|
bascule-auth-agent-id = { path = "../bascule-auth-agent-id", optional = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|
||||||
|
# OTel export deferred — version compatibility WIP
|
||||||
|
# opentelemetry, opentelemetry-otlp, opentelemetry_sdk, tracing-opentelemetry
|
||||||
|
|
|
||||||
|
|
@ -21,44 +21,49 @@ struct Cli {
|
||||||
config: Option<String>,
|
config: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_tracing(config: &BasculeConfig) {
|
||||||
|
// Structured logging (JSON if BASCULE_LOG_FORMAT=json, otherwise pretty)
|
||||||
|
// OTel OTLP export: deferred to future release (version compatibility WIP)
|
||||||
|
let json_format = std::env::var("BASCULE_LOG_FORMAT")
|
||||||
|
.map(|v| v == "json")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if json_format {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||||
|
.json()
|
||||||
|
.with_target(true)
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
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 cli = Cli::parse();
|
||||||
|
|
||||||
let config = match &cli.config {
|
let config = match &cli.config {
|
||||||
Some(path) => {
|
Some(path) => BasculeConfig::from_file(path)?,
|
||||||
tracing::info!(path = %path, "Loading configuration");
|
None => BasculeConfig::default(),
|
||||||
BasculeConfig::from_file(path)?
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::info!("No config file specified, using defaults (accept-all auth, port 2222)");
|
|
||||||
BasculeConfig::default()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
init_tracing(&config);
|
||||||
|
|
||||||
|
let backend = if config.proxy.is_some() { "proxy" }
|
||||||
|
else if config.container.is_some() { "container" }
|
||||||
|
else { "pty" };
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
listen = %config.listen_addr,
|
listen = %config.listen_addr,
|
||||||
auth = %config.auth.mode,
|
auth = %config.auth.mode,
|
||||||
|
backend = %backend,
|
||||||
shell = ?config.shell_command,
|
shell = ?config.shell_command,
|
||||||
"Bascule starting"
|
"Bascule starting"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Select auth provider based on config
|
let server = BasculeServer::new(config, AcceptAllKeys, DefaultHandler)?;
|
||||||
let auth_mode = config.auth.mode.as_str();
|
server.run().await
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
docs/architecture.md
Normal file
90
docs/architecture.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bascule is a single-binary SSH proxy with three pluggable layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
Client SSH → Bascule Server
|
||||||
|
│
|
||||||
|
├── AuthProvider (SSH keys / OIDC / Agent ID)
|
||||||
|
│
|
||||||
|
├── SessionHandler hooks
|
||||||
|
│ on_session_start → build_session_env → on_exec → on_session_end
|
||||||
|
│
|
||||||
|
└── SessionBackend
|
||||||
|
├── LocalPty (portable-pty → /bin/bash)
|
||||||
|
├── RemoteProxy (upstream SSH → target host)
|
||||||
|
└── Container (docker/podman → ephemeral image)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Backends
|
||||||
|
|
||||||
|
### Local PTY (default)
|
||||||
|
|
||||||
|
Spawns a process on this machine via `portable-pty`. The process gets a real PTY with terminal emulation, resize support, and environment variables.
|
||||||
|
|
||||||
|
### Remote Proxy
|
||||||
|
|
||||||
|
Opens a second SSH connection to a target host via `russh` client. Bridges I/O between the client and upstream channels. The client sees a transparent connection to the target host, but Bascule mediates authentication and applies SessionHandler hooks.
|
||||||
|
|
||||||
|
### Container
|
||||||
|
|
||||||
|
Spawns an ephemeral container per session using docker, podman, or nerdctl. The container is destroyed on disconnect. The container image defines what tools are available — if it's not in the image, the operator can't use it.
|
||||||
|
|
||||||
|
## Extension Model
|
||||||
|
|
||||||
|
### AuthProvider trait
|
||||||
|
|
||||||
|
Implement to add custom authentication:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AuthProvider: Send + Sync + 'static {
|
||||||
|
async fn check_public_key(&self, user: &str, key: &PublicKey) -> bool;
|
||||||
|
async fn check_password(&self, user: &str, password: &str) -> bool;
|
||||||
|
fn principal_for_user(&self, user: &str) -> String;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionHandler trait
|
||||||
|
|
||||||
|
Implement to add policy, audit, or custom behavior:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SessionHandler: Send + Sync + 'static {
|
||||||
|
async fn on_session_start(&self, session: &SessionInfo) -> anyhow::Result<()>;
|
||||||
|
async fn build_session_env(&self, session: &SessionInfo) -> HashMap<String, String>;
|
||||||
|
async fn on_exec(&self, session: &SessionInfo, command: &str) -> anyhow::Result<()>;
|
||||||
|
async fn on_session_end(&self, session: &SessionInfo) -> anyhow::Result<()>;
|
||||||
|
fn display_name(&self, session: &SessionInfo) -> String;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All methods have default implementations (accept/passthrough). Override only what you need.
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
### Container hardening (default)
|
||||||
|
|
||||||
|
When `hardened = true`:
|
||||||
|
- `--security-opt no-new-privileges`
|
||||||
|
- `--cap-drop ALL`
|
||||||
|
- `--cap-add SETUID --cap-add SETGID` (minimal for shell)
|
||||||
|
|
||||||
|
### Ephemeral sessions
|
||||||
|
|
||||||
|
When `ephemeral = true` (default), the container is `--rm` and destroyed on disconnect. Nothing persists between sessions.
|
||||||
|
|
||||||
|
### Network isolation
|
||||||
|
|
||||||
|
Set `network = "none"` to completely isolate the container from the network. The operator can run local tools but can't reach external services.
|
||||||
|
|
||||||
|
## Crate Structure
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `bascule-core` | Library — server, handler, auth, PTY, proxy, container, hooks |
|
||||||
|
| `bascule-server` | Binary — CLI, config loading, tracing setup |
|
||||||
|
| `bascule-auth-agent-id` | Optional — Entra Agent ID authentication |
|
||||||
72
docs/authentication.md
Normal file
72
docs/authentication.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
Bascule supports multiple authentication methods. Configure via `[auth]` in your TOML config.
|
||||||
|
|
||||||
|
## accept-all (Development Only)
|
||||||
|
|
||||||
|
Accepts any SSH key or password. **Never use in production.**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
mode = "accept-all"
|
||||||
|
```
|
||||||
|
|
||||||
|
## authorized-keys
|
||||||
|
|
||||||
|
Standard SSH authorized_keys file, same format as OpenSSH.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
mode = "authorized-keys"
|
||||||
|
authorized_keys_path = "/etc/bascule/authorized_keys"
|
||||||
|
```
|
||||||
|
|
||||||
|
The file format is identical to `~/.ssh/authorized_keys`:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh-ed25519 AAAAC3NzaC1l... user@host
|
||||||
|
ssh-rsa AAAAB3NzaC1yc2... another-user@host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entra Agent ID (AI Agents)
|
||||||
|
|
||||||
|
Microsoft Entra Agent ID authentication for AI agents. Agents present their OAuth token as the SSH password.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
mode = "accept-all" # For human SSH key auth (or authorized-keys)
|
||||||
|
|
||||||
|
[auth.agent_id]
|
||||||
|
tenant_id = "your-entra-tenant-id"
|
||||||
|
audiences = ["api://bascule-proxy"]
|
||||||
|
multi_tenant = false
|
||||||
|
```
|
||||||
|
|
||||||
|
### How agents authenticate
|
||||||
|
|
||||||
|
1. Agent obtains an OAuth token from Entra via `client_credentials` flow
|
||||||
|
2. Agent connects via SSH: `ssh agent-name@proxy -p 2222`
|
||||||
|
3. Agent provides the OAuth token as the SSH password
|
||||||
|
4. Bascule validates the token against Entra's JWKS
|
||||||
|
5. Session created with `auth_method: "agent-id"` and full agent metadata
|
||||||
|
|
||||||
|
### Agent metadata extracted
|
||||||
|
|
||||||
|
From the validated token, Bascule extracts:
|
||||||
|
- Agent application ID
|
||||||
|
- Display name
|
||||||
|
- Agent type (from custom claims)
|
||||||
|
- Blueprint ID (Entra Agent ID template)
|
||||||
|
- Sponsor (human/org that registered the agent)
|
||||||
|
- On-behalf-of (if agent is delegated)
|
||||||
|
- Scopes and roles
|
||||||
|
|
||||||
|
Your `SessionHandler` receives this in `SessionInfo` and can apply different policies for human vs agent sessions.
|
||||||
|
|
||||||
|
## Composing Auth Providers
|
||||||
|
|
||||||
|
Bascule tries auth methods in order:
|
||||||
|
1. SSH public key (if configured)
|
||||||
|
2. Password / token-as-password (if configured)
|
||||||
|
|
||||||
|
Humans use SSH keys. Agents use token-as-password. Both work through the same SSH server.
|
||||||
40
docs/comparison.md
Normal file
40
docs/comparison.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Comparison
|
||||||
|
|
||||||
|
| Feature | Bascule | Teleport | Boundary | StrongDM |
|
||||||
|
|---------|---------|----------|----------|----------|
|
||||||
|
| License | Apache 2.0 | AGPL / Commercial | MPL / Commercial | Commercial |
|
||||||
|
| Agents required | No | Yes | Yes | Yes |
|
||||||
|
| Control plane | No | Required | Required | SaaS |
|
||||||
|
| Container sessions | Native | Via agents | No | No |
|
||||||
|
| AI Agent Identity | Native (Entra Agent ID) | No | No | No |
|
||||||
|
| Binary size | ~7MB | ~150MB | ~100MB | N/A (SaaS) |
|
||||||
|
| Auth | SSH keys, OIDC, Certs, Agent ID | OIDC, SAML, GitHub | OIDC, LDAP | SAML, OIDC |
|
||||||
|
| Session recording | Via SessionHandler | Built-in | Built-in | Built-in |
|
||||||
|
| Kubernetes | Any (pod) | Requires agent | Requires worker | SaaS |
|
||||||
|
| Extensibility | SessionHandler trait | Plugin system | No | No |
|
||||||
|
| Proxy mode | Built-in | Built-in | Built-in | SaaS |
|
||||||
|
| Config | Single TOML file | Complex YAML | Complex HCL | Web UI |
|
||||||
|
|
||||||
|
## When to choose Bascule
|
||||||
|
|
||||||
|
- You want a lightweight SSH proxy without a control plane
|
||||||
|
- You need ephemeral container sessions per connection
|
||||||
|
- You need AI agent identity (Entra Agent ID) alongside human SSH
|
||||||
|
- You want to extend the proxy with custom policy via a Rust trait
|
||||||
|
- You want Apache 2.0 licensing without AGPL constraints
|
||||||
|
- You want a single binary under 10MB
|
||||||
|
|
||||||
|
## When to choose Teleport
|
||||||
|
|
||||||
|
- You need a full access management platform (SSH + K8s + DB + Web)
|
||||||
|
- You need built-in session recording with search
|
||||||
|
- You need desktop application access
|
||||||
|
- You have a large team and need role-based access at scale
|
||||||
|
- AGPL licensing is acceptable for your use case
|
||||||
|
|
||||||
|
## When to choose Boundary
|
||||||
|
|
||||||
|
- You're fully invested in the HashiCorp ecosystem
|
||||||
|
- You need dynamic credential injection
|
||||||
|
- You need multi-hop proxy chains
|
||||||
|
- MPL licensing works for your organization
|
||||||
128
docs/configuration.md
Normal file
128
docs/configuration.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
# Configuration Reference
|
||||||
|
|
||||||
|
Bascule uses a TOML configuration file. Pass it with `--config path/to/config.toml`.
|
||||||
|
|
||||||
|
## Top-Level
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `listen_addr` | string | `0.0.0.0:2222` | Address to listen on |
|
||||||
|
| `host_key_path` | string | (generated) | Path to SSH host key |
|
||||||
|
| `shell_command` | string | `/bin/bash` | Shell to spawn (local PTY mode) |
|
||||||
|
| `shell_args` | list | `[]` | Arguments for shell_command |
|
||||||
|
| `banner` | string | `Welcome, {name}.` | Session banner |
|
||||||
|
| `max_sessions` | int | `0` | Max concurrent sessions (0 = unlimited) |
|
||||||
|
|
||||||
|
## `[auth]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `mode` | string | `accept-all` | Auth mode: `accept-all`, `authorized-keys` |
|
||||||
|
| `authorized_keys_path` | string | — | Path to authorized_keys file |
|
||||||
|
|
||||||
|
### `[auth.agent_id]` (Entra Agent ID)
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `tenant_id` | string | — | Entra tenant ID |
|
||||||
|
| `audiences` | list | `[]` | Expected token audiences |
|
||||||
|
| `multi_tenant` | bool | `false` | Accept agents from any tenant |
|
||||||
|
|
||||||
|
## `[proxy]`
|
||||||
|
|
||||||
|
When set, sessions are forwarded to a target SSH host.
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `target_host` | string | — | Target SSH host |
|
||||||
|
| `target_port` | int | `22` | Target SSH port |
|
||||||
|
| `target_user` | string | (principal) | Username on target |
|
||||||
|
| `target_key_path` | string | — | Private key for target auth |
|
||||||
|
| `accept_target_host_key` | bool | `false` | Accept any target host key (dev only) |
|
||||||
|
|
||||||
|
## `[container]`
|
||||||
|
|
||||||
|
When set, sessions spawn an ephemeral container.
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `runtime` | string | `auto` | `docker`, `podman`, `nerdctl`, `auto` |
|
||||||
|
| `image` | string | — | Container image |
|
||||||
|
| `pull_policy` | string | `if-not-present` | `always`, `if-not-present`, `never` |
|
||||||
|
| `mounts` | list | `[]` | Volume mounts |
|
||||||
|
| `env` | map | `{}` | Extra environment variables |
|
||||||
|
| `memory_limit` | string | — | Memory limit (e.g. `512m`) |
|
||||||
|
| `cpu_limit` | string | — | CPU limit (e.g. `1.0`) |
|
||||||
|
| `shell` | string | (image default) | Shell command in container |
|
||||||
|
| `user` | string | — | User to run as |
|
||||||
|
| `ephemeral` | bool | `true` | Destroy container on disconnect |
|
||||||
|
| `hardened` | bool | `true` | Drop all caps, add minimal set |
|
||||||
|
| `read_only_rootfs` | bool | `false` | Read-only root filesystem |
|
||||||
|
| `network` | string | — | Network mode (`none`, `bridge`, `host`) |
|
||||||
|
|
||||||
|
### Mount format
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[container.mounts]]
|
||||||
|
source = "/host/path"
|
||||||
|
target = "/container/path"
|
||||||
|
readonly = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## `[telemetry]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `otlp_endpoint` | string | — | OTLP endpoint for trace export |
|
||||||
|
| `service_name` | string | `bascule` | OTel service name |
|
||||||
|
|
||||||
|
## `[metrics]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | `false` | Enable Prometheus `/metrics` endpoint |
|
||||||
|
| `port` | int | `9090` | Metrics server port |
|
||||||
|
|
||||||
|
## Example Configs
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```toml
|
||||||
|
listen_addr = "127.0.0.1:2222"
|
||||||
|
[auth]
|
||||||
|
mode = "accept-all"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (containers + SSH keys)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
listen_addr = "0.0.0.0:2222"
|
||||||
|
host_key_path = "/etc/bascule/host_key"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "authorized-keys"
|
||||||
|
authorized_keys_path = "/etc/bascule/authorized_keys"
|
||||||
|
|
||||||
|
[container]
|
||||||
|
image = "bascule-shell:k8s-ops"
|
||||||
|
ephemeral = true
|
||||||
|
hardened = true
|
||||||
|
memory_limit = "512m"
|
||||||
|
network = "none"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jumphost (proxy)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
listen_addr = "0.0.0.0:2222"
|
||||||
|
host_key_path = "/etc/bascule/host_key"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "authorized-keys"
|
||||||
|
authorized_keys_path = "/etc/bascule/authorized_keys"
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
target_host = "10.0.1.50"
|
||||||
|
target_port = 22
|
||||||
|
target_key_path = "/etc/bascule/target_key"
|
||||||
|
```
|
||||||
61
docs/observability.md
Normal file
61
docs/observability.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Observability
|
||||||
|
|
||||||
|
## Structured Logging
|
||||||
|
|
||||||
|
Bascule logs structured events via the `tracing` crate. Every log includes session context (session ID, principal, backend, source IP).
|
||||||
|
|
||||||
|
### JSON format
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASCULE_LOG_FORMAT=json ./bascule --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"timestamp":"2026-04-04T20:30:00Z","level":"INFO","message":"Auth accepted","method":"ssh-key","principal":"tking","peer":"192.168.1.10:54321"}
|
||||||
|
{"timestamp":"2026-04-04T20:30:00Z","level":"INFO","message":"Shell session starting","session.id":"abc-123","session.principal":"tking","session.backend":"container","session.source_ip":"192.168.1.10"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log levels
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug ./bascule --config config.toml # verbose
|
||||||
|
RUST_LOG=info ./bascule --config config.toml # standard
|
||||||
|
RUST_LOG=warn ./bascule --config config.toml # quiet
|
||||||
|
RUST_LOG=bascule=debug ./bascule --config config.toml # debug bascule only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Events
|
||||||
|
|
||||||
|
| Event | Level | When |
|
||||||
|
|-------|-------|------|
|
||||||
|
| Auth accepted | INFO | SSH authentication succeeds |
|
||||||
|
| Auth rejected | WARN | SSH authentication fails |
|
||||||
|
| Shell session starting | INFO | New session with backend type |
|
||||||
|
| Exec request | INFO | Non-interactive command execution |
|
||||||
|
| Container spawning | INFO | Container session starting |
|
||||||
|
| Upstream connected | INFO | Proxy session connected to target |
|
||||||
|
| Session ended | INFO | Disconnect or exit |
|
||||||
|
|
||||||
|
## OTel Tracing (Planned)
|
||||||
|
|
||||||
|
OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Session lifecycle maps to OTel spans:
|
||||||
|
|
||||||
|
```
|
||||||
|
session (root)
|
||||||
|
├── auth (ssh-key / oidc / agent-id)
|
||||||
|
├── backend_setup (pty / proxy / container)
|
||||||
|
└── session_active (commands, I/O)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prometheus Metrics (Planned)
|
||||||
|
|
||||||
|
Prometheus-compatible metrics endpoint planned as `--features metrics`:
|
||||||
|
|
||||||
|
```
|
||||||
|
bascule_sessions_total{backend,auth_method,outcome}
|
||||||
|
bascule_sessions_active
|
||||||
|
bascule_session_duration_seconds{backend}
|
||||||
|
bascule_auth_attempts_total{method,outcome}
|
||||||
|
```
|
||||||
64
docs/quickstart.md
Normal file
64
docs/quickstart.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Quick Start
|
||||||
|
|
||||||
|
## Option 1: Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/guildhouse/bascule.git
|
||||||
|
cd bascule
|
||||||
|
cargo build --release -p bascule-server
|
||||||
|
./target/release/bascule --config config/bascule.example.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 2: Container Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build a curated shell image
|
||||||
|
docker build -t bascule-shell:k8s-ops images/k8s-ops/
|
||||||
|
|
||||||
|
# Create a config
|
||||||
|
cat > my-config.toml << 'TOML'
|
||||||
|
listen_addr = "0.0.0.0:2222"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "accept-all" # For testing only!
|
||||||
|
|
||||||
|
[container]
|
||||||
|
image = "bascule-shell:k8s-ops"
|
||||||
|
ephemeral = true
|
||||||
|
hardened = true
|
||||||
|
TOML
|
||||||
|
|
||||||
|
./target/release/bascule --config my-config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 3: Proxy Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > proxy-config.toml << 'TOML'
|
||||||
|
listen_addr = "0.0.0.0:2222"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
mode = "accept-all"
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
target_host = "192.168.1.100"
|
||||||
|
target_port = 22
|
||||||
|
target_key_path = "/path/to/key"
|
||||||
|
accept_target_host_key = true
|
||||||
|
TOML
|
||||||
|
|
||||||
|
./target/release/bascule --config proxy-config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Configuration Reference](configuration.md)
|
||||||
|
- [Authentication Setup](authentication.md)
|
||||||
|
- [Architecture Overview](architecture.md)
|
||||||
|
- [Container Images](../images/README.md)
|
||||||
34
images/README.md
Normal file
34
images/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Bascule Shell Images
|
||||||
|
|
||||||
|
Curated container images for right-sized operator environments.
|
||||||
|
|
||||||
|
| Image | Contents | Approx. Size |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `bascule-shell:minimal` | bash, coreutils, curl, jq, ssh | ~50MB |
|
||||||
|
| `bascule-shell:k8s-ops` | + kubectl, helm | ~120MB |
|
||||||
|
| `bascule-shell:net-ops` | + nmap, dig, traceroute, tcpdump, nft | ~90MB |
|
||||||
|
| `bascule-shell:dev` | + git, make, gcc, python3, vim | ~250MB |
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t bascule-shell:minimal images/minimal/
|
||||||
|
docker build -t bascule-shell:k8s-ops images/k8s-ops/
|
||||||
|
docker build -t bascule-shell:net-ops images/net-ops/
|
||||||
|
docker build -t bascule-shell:dev images/dev/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Right-Sizing
|
||||||
|
|
||||||
|
Choose the smallest image that contains the tools your operators need. If it's not in the image, the operator can't use it — the container IS the access boundary.
|
||||||
|
|
||||||
|
## Custom Images
|
||||||
|
|
||||||
|
Build your own image from any base:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM bascule-shell:k8s-ops
|
||||||
|
USER root
|
||||||
|
RUN microdnf install -y your-custom-tool
|
||||||
|
USER operator
|
||||||
|
```
|
||||||
Loading…
Reference in a new issue