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:
Tyler King 2026-04-04 23:45:03 -04:00
parent 8d789524e8
commit e7fc9fa5e1
13 changed files with 697 additions and 27 deletions

14
Cargo.lock generated
View file

@ -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
View 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

View file

@ -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 }

View file

@ -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);

View file

@ -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

View file

@ -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 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)?; let server = BasculeServer::new(config, AcceptAllKeys, DefaultHandler)?;
server.run().await server.run().await
} }
}
}

90
docs/architecture.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
```