feat: container backend — ephemeral right-sized shell containers
Third session backend: per-session ephemeral containers. SSH session → container spawns → operator works → disconnect → destroyed. Container runtime abstraction: Docker, Podman, Nerdctl via CLI execution (auto-detect) No libdocker dependency — any OCI-compliant runtime Container config ([container] section): image, pull_policy, mounts, env, memory/cpu limits ephemeral (destroy on exit), hardened (drop caps) read_only_rootfs, network mode, user override Handler: SessionBackend enum now has three variants: Local(PtyBridge) — spawn local shell Proxy(UpstreamSession) — forward to remote SSH host Container(ContainerSession) — spawn ephemeral container Priority: proxy > container > local PTY Curated base images (images/): minimal — bash, coreutils, curl, jq, ssh (~50MB) k8s-ops — + kubectl, helm (~120MB) net-ops — + nmap, dig, traceroute, tcpdump (~90MB) dev — + git, make, gcc, python3 (~250MB) The container IS the access boundary: if it's not in the image, the operator can't run it. SessionHandler hooks fire in all three modes. 6.5MB binary, 0 substrate deps, 1197 lines bascule-core. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2212f7f870
commit
8d789524e8
8 changed files with 439 additions and 108 deletions
|
|
@ -34,6 +34,11 @@ pub struct BasculeConfig {
|
|||
/// When set, sessions are forwarded to a target SSH host
|
||||
/// instead of spawning a local shell.
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
|
||||
/// Container backend configuration.
|
||||
/// When set, sessions spawn an ephemeral container per connection.
|
||||
/// Priority: proxy > container > local PTY.
|
||||
pub container: Option<ContainerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
|
|
@ -90,6 +95,7 @@ impl Default for BasculeConfig {
|
|||
banner: None,
|
||||
max_sessions: 0,
|
||||
proxy: None,
|
||||
container: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,3 +122,53 @@ fn default_auth_mode() -> String {
|
|||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
/// Container backend configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ContainerConfig {
|
||||
/// Container runtime: "docker", "podman", "nerdctl", "auto".
|
||||
#[serde(default = "default_runtime")]
|
||||
pub runtime: String,
|
||||
/// Container image to use.
|
||||
pub image: String,
|
||||
/// Image pull policy: "always", "if-not-present", "never".
|
||||
#[serde(default = "default_pull_policy")]
|
||||
pub pull_policy: String,
|
||||
/// Volume mounts.
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<MountConfig>,
|
||||
/// Extra environment variables.
|
||||
#[serde(default)]
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// Memory limit (e.g. "512m", "1g").
|
||||
pub memory_limit: Option<String>,
|
||||
/// CPU limit (e.g. "1.0", "0.5").
|
||||
pub cpu_limit: Option<String>,
|
||||
/// Shell to run inside the container.
|
||||
pub shell: Option<String>,
|
||||
/// User to run as inside the container.
|
||||
pub user: Option<String>,
|
||||
/// Destroy container on session end (default: true).
|
||||
#[serde(default = "default_true")]
|
||||
pub ephemeral: bool,
|
||||
/// Drop all capabilities, add back minimal set (default: true).
|
||||
#[serde(default = "default_true")]
|
||||
pub hardened: bool,
|
||||
/// Read-only root filesystem.
|
||||
#[serde(default)]
|
||||
pub read_only_rootfs: bool,
|
||||
/// Network mode (e.g. "none", "bridge", "host").
|
||||
pub network: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MountConfig {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
#[serde(default)]
|
||||
pub readonly: bool,
|
||||
}
|
||||
|
||||
fn default_runtime() -> String { "auto".to_string() }
|
||||
fn default_pull_policy() -> String { "if-not-present".to_string() }
|
||||
fn default_true() -> bool { true }
|
||||
|
|
|
|||
240
crates/bascule-core/src/container.rs
Normal file
240
crates/bascule-core/src/container.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! Container backend — ephemeral per-session containers.
|
||||
//!
|
||||
//! Each SSH session spawns a fresh container from a configured image.
|
||||
//! The container is destroyed when the session ends.
|
||||
//!
|
||||
//! ```text
|
||||
//! SSH client ←→ bascule (auth + hooks) ←→ container (docker/podman/nerdctl)
|
||||
//! ```
|
||||
//!
|
||||
//! Uses CLI execution for maximum portability — no libdocker dependency.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::config::ContainerConfig;
|
||||
|
||||
/// Supported container runtimes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContainerRuntime {
|
||||
Docker,
|
||||
Podman,
|
||||
Nerdctl,
|
||||
}
|
||||
|
||||
impl ContainerRuntime {
|
||||
fn binary(&self) -> &str {
|
||||
match self {
|
||||
Self::Docker => "docker",
|
||||
Self::Podman => "podman",
|
||||
Self::Nerdctl => "nerdctl",
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-detect an available container runtime.
|
||||
pub fn detect() -> Option<Self> {
|
||||
for (runtime, bin) in [
|
||||
(Self::Docker, "docker"),
|
||||
(Self::Podman, "podman"),
|
||||
(Self::Nerdctl, "nerdctl"),
|
||||
] {
|
||||
if std::process::Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
{
|
||||
tracing::info!(runtime = bin, "Auto-detected container runtime");
|
||||
return Some(runtime);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse from config string.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"docker" => Some(Self::Docker),
|
||||
"podman" => Some(Self::Podman),
|
||||
"nerdctl" => Some(Self::Nerdctl),
|
||||
"auto" => Self::detect(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages container lifecycle for a session.
|
||||
pub struct ContainerSession {
|
||||
pub child: Child,
|
||||
pub session_id: String,
|
||||
runtime: ContainerRuntime,
|
||||
}
|
||||
|
||||
impl ContainerSession {
|
||||
/// Spawn an ephemeral container for the given session.
|
||||
pub async fn spawn(
|
||||
session_id: &str,
|
||||
config: &ContainerConfig,
|
||||
extra_env: &HashMap<String, String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let runtime = ContainerRuntime::from_str(&config.runtime)
|
||||
.ok_or_else(|| anyhow::anyhow!("No container runtime available"))?;
|
||||
|
||||
// Pull image if needed
|
||||
pull_image(&runtime, &config.image, &config.pull_policy).await?;
|
||||
|
||||
let container_name = format!("bascule-{}", session_id);
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
"--interactive".to_string(),
|
||||
"--tty".to_string(),
|
||||
"--rm".to_string(),
|
||||
"--name".to_string(),
|
||||
container_name,
|
||||
"--hostname".to_string(),
|
||||
format!("bascule-{}", &session_id[..8.min(session_id.len())]),
|
||||
];
|
||||
|
||||
// Resource limits
|
||||
if let Some(ref mem) = config.memory_limit {
|
||||
args.extend(["--memory".into(), mem.clone()]);
|
||||
}
|
||||
if let Some(ref cpu) = config.cpu_limit {
|
||||
args.extend(["--cpus".into(), cpu.clone()]);
|
||||
}
|
||||
|
||||
// User
|
||||
if let Some(ref user) = config.user {
|
||||
args.extend(["--user".into(), user.clone()]);
|
||||
}
|
||||
|
||||
// Mounts
|
||||
for mount in &config.mounts {
|
||||
let mount_str = if mount.readonly {
|
||||
format!("{}:{}:ro", mount.source, mount.target)
|
||||
} else {
|
||||
format!("{}:{}", mount.source, mount.target)
|
||||
};
|
||||
args.extend(["-v".into(), mount_str]);
|
||||
}
|
||||
|
||||
// Environment from config
|
||||
for (key, val) in &config.env {
|
||||
args.extend(["-e".into(), format!("{}={}", key, val)]);
|
||||
}
|
||||
|
||||
// Environment from SessionHandler
|
||||
for (key, val) in extra_env {
|
||||
args.extend(["-e".into(), format!("{}={}", key, val)]);
|
||||
}
|
||||
|
||||
// Security hardening
|
||||
if config.hardened {
|
||||
args.extend([
|
||||
"--security-opt".into(), "no-new-privileges".into(),
|
||||
"--cap-drop".into(), "ALL".into(),
|
||||
"--cap-add".into(), "SETUID".into(),
|
||||
"--cap-add".into(), "SETGID".into(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Read-only rootfs
|
||||
if config.read_only_rootfs {
|
||||
args.extend([
|
||||
"--read-only".into(),
|
||||
"--tmpfs".into(), "/tmp:rw,noexec,nosuid,size=64m".into(),
|
||||
"--tmpfs".into(), "/run:rw,noexec,nosuid,size=16m".into(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Network mode
|
||||
if let Some(ref network) = config.network {
|
||||
args.extend(["--network".into(), network.clone()]);
|
||||
}
|
||||
|
||||
// Image
|
||||
args.push(config.image.clone());
|
||||
|
||||
// Shell command override
|
||||
if let Some(ref shell) = config.shell {
|
||||
args.push(shell.clone());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
runtime = runtime.binary(),
|
||||
image = %config.image,
|
||||
session = session_id,
|
||||
"Spawning container session"
|
||||
);
|
||||
|
||||
let child = Command::new(runtime.binary())
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn container: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
session_id: session_id.to_string(),
|
||||
runtime,
|
||||
})
|
||||
}
|
||||
|
||||
/// Force-kill the container.
|
||||
pub async fn kill(&self) {
|
||||
let container_name = format!("bascule-{}", self.session_id);
|
||||
let _ = Command::new(self.runtime.binary())
|
||||
.args(["rm", "-f", &container_name])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
tracing::info!(session = %self.session_id, "Container killed");
|
||||
}
|
||||
}
|
||||
|
||||
async fn pull_image(runtime: &ContainerRuntime, image: &str, policy: &str) -> anyhow::Result<()> {
|
||||
match policy {
|
||||
"never" => Ok(()),
|
||||
"always" => {
|
||||
tracing::info!(image = image, "Pulling container image");
|
||||
let status = Command::new(runtime.binary())
|
||||
.args(["pull", image])
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.await?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to pull image: {}", image);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
// if-not-present: check if image exists locally
|
||||
let output = Command::new(runtime.binary())
|
||||
.args(["image", "inspect", image])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await?;
|
||||
if !output.success() {
|
||||
// Not present — pull it
|
||||
tracing::info!(image = image, "Image not present locally, pulling");
|
||||
let status = Command::new(runtime.binary())
|
||||
.args(["pull", image])
|
||||
.stdout(Stdio::null())
|
||||
.status()
|
||||
.await?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to pull image: {}", image);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
//! SSH handler — implements russh's Handler trait.
|
||||
//!
|
||||
//! Supports two modes:
|
||||
//! - **Local mode** — spawns a PTY on this machine (default)
|
||||
//! - **Proxy mode** — forwards the session to a target SSH host
|
||||
//! Supports three session modes:
|
||||
//! - **Local PTY** — spawns a shell on this machine (default)
|
||||
//! - **Remote proxy** — forwards to a target SSH host
|
||||
//! - **Container** — spawns an ephemeral container per session
|
||||
//!
|
||||
//! SessionHandler hooks fire in both modes at the same lifecycle points.
|
||||
//! SessionHandler hooks fire in all modes at the same lifecycle points.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
|
@ -13,19 +14,22 @@ use async_trait::async_trait;
|
|||
use russh::server::{Auth, Handler, Msg, Session};
|
||||
use russh::{Channel, ChannelId, CryptoVec};
|
||||
use russh_keys::key;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::auth::AuthProvider;
|
||||
use crate::config::BasculeConfig;
|
||||
use crate::container::ContainerSession;
|
||||
use crate::hooks::SessionHandler;
|
||||
use crate::proxy::{self, UpstreamSession};
|
||||
use crate::pty::{self, PtyBridge};
|
||||
use crate::session::SessionInfo;
|
||||
|
||||
/// Backend for an active session — either a local PTY or a remote proxy.
|
||||
/// Backend for an active session.
|
||||
enum SessionBackend {
|
||||
Local(Arc<Mutex<PtyBridge>>),
|
||||
Proxy(Arc<Mutex<UpstreamSession>>),
|
||||
Container(Arc<Mutex<ContainerSession>>),
|
||||
}
|
||||
|
||||
/// Per-connection SSH handler.
|
||||
|
|
@ -66,11 +70,7 @@ impl BasculeHandler {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_proxy_mode(&self) -> bool {
|
||||
self.config.proxy.is_some()
|
||||
}
|
||||
|
||||
/// Start local PTY mode — spawn a shell on this machine.
|
||||
/// Start local PTY mode.
|
||||
async fn start_local(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
|
|
@ -78,8 +78,7 @@ impl BasculeHandler {
|
|||
command: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let info = self.session_info.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||
.clone();
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
||||
|
||||
self.session_handler.on_session_start(&info).await?;
|
||||
|
||||
|
|
@ -90,10 +89,7 @@ impl BasculeHandler {
|
|||
|
||||
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())
|
||||
}
|
||||
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)?;
|
||||
|
|
@ -111,17 +107,14 @@ impl BasculeHandler {
|
|||
Ok(n) => n,
|
||||
}
|
||||
};
|
||||
if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() { break; }
|
||||
}
|
||||
let _ = handle.close(channel).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start proxy mode — connect to upstream SSH host and bridge I/O.
|
||||
/// Start proxy mode.
|
||||
async fn start_proxy(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
|
|
@ -129,8 +122,7 @@ impl BasculeHandler {
|
|||
command: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let info = self.session_info.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||
.clone();
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
||||
|
||||
self.session_handler.on_session_start(&info).await?;
|
||||
|
||||
|
|
@ -139,49 +131,98 @@ impl BasculeHandler {
|
|||
|
||||
let mut upstream = proxy::connect_upstream(proxy_config, &info.principal).await?;
|
||||
|
||||
// Request PTY on the upstream channel
|
||||
let upstream_ch = upstream.channel.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No upstream channel"))?;
|
||||
upstream_ch.request_pty(
|
||||
true,
|
||||
"xterm-256color",
|
||||
self.pty_cols as u32,
|
||||
self.pty_rows as u32,
|
||||
0, 0,
|
||||
&[],
|
||||
).await?;
|
||||
upstream_ch.request_pty(true, "xterm-256color", self.pty_cols as u32, self.pty_rows as u32, 0, 0, &[]).await?;
|
||||
|
||||
// Request shell or exec on upstream
|
||||
match command {
|
||||
Some(cmd) => upstream_ch.exec(true, cmd).await?,
|
||||
None => upstream_ch.request_shell(true).await?,
|
||||
}
|
||||
|
||||
// Take the channel out for the bridge loop (bridge owns it)
|
||||
let upstream_channel = upstream.channel.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel already taken"))?;
|
||||
|
||||
let upstream = Arc::new(Mutex::new(upstream));
|
||||
self.backend = Some(SessionBackend::Proxy(upstream.clone()));
|
||||
|
||||
// Bridge upstream → client
|
||||
let server_handle = session.handle();
|
||||
tokio::spawn(async move {
|
||||
proxy::bridge_upstream_to_client(upstream_channel, server_handle, channel).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start container mode — spawn an ephemeral container.
|
||||
async fn start_container(
|
||||
&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();
|
||||
|
||||
self.session_handler.on_session_start(&info).await?;
|
||||
|
||||
let container_config = self.config.container.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container config missing"))?;
|
||||
|
||||
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 container = ContainerSession::spawn(
|
||||
&info.session_id,
|
||||
container_config,
|
||||
&env,
|
||||
).await?;
|
||||
|
||||
let container = Arc::new(Mutex::new(container));
|
||||
self.backend = Some(SessionBackend::Container(container.clone()));
|
||||
|
||||
// Bridge container stdout → SSH channel
|
||||
let handle = session.handle();
|
||||
let container_for_read = container.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = {
|
||||
let mut c = container_for_read.lock().await;
|
||||
let stdout = c.child.stdout.as_mut();
|
||||
match stdout {
|
||||
Some(out) => match out.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
},
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() { break; }
|
||||
}
|
||||
|
||||
// Container process exited — clean up
|
||||
{
|
||||
let c = container_for_read.lock().await;
|
||||
c.kill().await;
|
||||
}
|
||||
let _ = handle.close(channel).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start session in the appropriate mode.
|
||||
/// Select and start the appropriate backend.
|
||||
async fn start_session(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
command: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.is_proxy_mode() {
|
||||
if self.config.proxy.is_some() {
|
||||
self.start_proxy(channel, session, command).await
|
||||
} else if self.config.container.is_some() {
|
||||
self.start_container(channel, session, command).await
|
||||
} else {
|
||||
self.start_local(channel, session, command).await
|
||||
}
|
||||
|
|
@ -192,95 +233,50 @@ impl BasculeHandler {
|
|||
impl Handler for BasculeHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
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 {
|
||||
let principal = self.auth.principal_for_user(user);
|
||||
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)
|
||||
} else {
|
||||
Ok(Auth::Reject { proceed_with_methods: None })
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let principal = self.auth.principal_for_user(user);
|
||||
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)
|
||||
} 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> {
|
||||
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> {
|
||||
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> {
|
||||
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;
|
||||
match &self.backend {
|
||||
Some(SessionBackend::Local(bridge)) => {
|
||||
let b = bridge.lock().await;
|
||||
let _ = b.resize(self.pty_cols, self.pty_rows);
|
||||
}
|
||||
Some(SessionBackend::Proxy(_)) => {
|
||||
// Window change in proxy mode requires the channel reference
|
||||
// which is owned by the bridge loop. The terminal may not
|
||||
// resize perfectly but the session continues.
|
||||
tracing::debug!("Window change in proxy mode (not forwarded)");
|
||||
}
|
||||
Some(SessionBackend::Local(bridge)) => { let b = bridge.lock().await; let _ = b.resize(self.pty_cols, self.pty_rows); }
|
||||
Some(SessionBackend::Proxy(_)) => { tracing::debug!("Window change in proxy mode (not forwarded)"); }
|
||||
Some(SessionBackend::Container(_)) => { tracing::debug!("Window change in container mode (handled by runtime)"); }
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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> {
|
||||
if let Some(info) = &self.session_info {
|
||||
let display = self.session_handler.display_name(info);
|
||||
let banner = self.config.banner.as_deref()
|
||||
|
|
@ -288,20 +284,13 @@ impl Handler for BasculeHandler {
|
|||
.unwrap_or_else(|| format!("Welcome, {}.\r\n", display));
|
||||
session.data(channel, CryptoVec::from_slice(banner.as_bytes()));
|
||||
}
|
||||
|
||||
session.request_success();
|
||||
self.start_session(channel, session, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
|
|
@ -310,18 +299,12 @@ impl Handler for BasculeHandler {
|
|||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
session.request_success();
|
||||
self.start_session(channel, session, Some(&command)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
data: &[u8],
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
async fn data(&mut self, _channel: ChannelId, data: &[u8], _session: &mut Session) -> Result<(), Self::Error> {
|
||||
match &self.backend {
|
||||
Some(SessionBackend::Local(bridge)) => {
|
||||
let mut b = bridge.lock().await;
|
||||
|
|
@ -332,6 +315,13 @@ impl Handler for BasculeHandler {
|
|||
let u = upstream.lock().await;
|
||||
let _ = u.handle.data(u.channel_id, CryptoVec::from_slice(data)).await;
|
||||
}
|
||||
Some(SessionBackend::Container(container)) => {
|
||||
let mut c = container.lock().await;
|
||||
if let Some(stdin) = c.child.stdin.as_mut() {
|
||||
let _ = stdin.write_all(data).await;
|
||||
let _ = stdin.flush().await;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod container;
|
||||
pub mod handler;
|
||||
pub mod hooks;
|
||||
pub mod proxy;
|
||||
|
|
|
|||
11
images/dev/Dockerfile
Normal file
11
images/dev/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM bascule-shell:minimal
|
||||
|
||||
USER root
|
||||
RUN microdnf install -y \
|
||||
git make gcc gcc-c++ \
|
||||
python3 python3-pip \
|
||||
vim \
|
||||
&& microdnf clean all
|
||||
|
||||
USER operator
|
||||
CMD ["/bin/bash"]
|
||||
12
images/k8s-ops/Dockerfile
Normal file
12
images/k8s-ops/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM bascule-shell:minimal
|
||||
|
||||
USER root
|
||||
|
||||
RUN curl -fsSLo /usr/local/bin/kubectl \
|
||||
"https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
|
||||
&& chmod +x /usr/local/bin/kubectl
|
||||
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
|
||||
USER operator
|
||||
CMD ["/bin/bash"]
|
||||
11
images/minimal/Dockerfile
Normal file
11
images/minimal/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM fedora:41-minimal
|
||||
RUN microdnf install -y \
|
||||
bash coreutils findutils grep sed gawk \
|
||||
curl wget jq ca-certificates less vim-minimal \
|
||||
openssh-clients \
|
||||
&& microdnf clean all
|
||||
|
||||
RUN useradd -m -s /bin/bash operator
|
||||
USER operator
|
||||
WORKDIR /home/operator
|
||||
CMD ["/bin/bash"]
|
||||
10
images/net-ops/Dockerfile
Normal file
10
images/net-ops/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM bascule-shell:minimal
|
||||
|
||||
USER root
|
||||
RUN microdnf install -y \
|
||||
nmap bind-utils traceroute tcpdump \
|
||||
nftables iproute iputils net-tools \
|
||||
&& microdnf clean all
|
||||
|
||||
USER operator
|
||||
CMD ["/bin/bash"]
|
||||
Loading…
Reference in a new issue