From 8d789524e8bcd8a59dc1e605b6caef67a1f9f084454d0d2023653efda7af10d6 Mon Sep 17 00:00:00 2001 From: Tyler King Date: Sat, 4 Apr 2026 23:23:39 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20container=20backend=20=E2=80=94=20ephem?= =?UTF-8?q?eral=20right-sized=20shell=20containers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/bascule-core/src/config.rs | 56 +++++++ crates/bascule-core/src/container.rs | 240 +++++++++++++++++++++++++++ crates/bascule-core/src/handler.rs | 206 +++++++++++------------ crates/bascule-core/src/lib.rs | 1 + images/dev/Dockerfile | 11 ++ images/k8s-ops/Dockerfile | 12 ++ images/minimal/Dockerfile | 11 ++ images/net-ops/Dockerfile | 10 ++ 8 files changed, 439 insertions(+), 108 deletions(-) create mode 100644 crates/bascule-core/src/container.rs create mode 100644 images/dev/Dockerfile create mode 100644 images/k8s-ops/Dockerfile create mode 100644 images/minimal/Dockerfile create mode 100644 images/net-ops/Dockerfile diff --git a/crates/bascule-core/src/config.rs b/crates/bascule-core/src/config.rs index 5da0ed5..e9c9747 100644 --- a/crates/bascule-core/src/config.rs +++ b/crates/bascule-core/src/config.rs @@ -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, + + /// Container backend configuration. + /// When set, sessions spawn an ephemeral container per connection. + /// Priority: proxy > container > local PTY. + pub container: Option, } #[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, + /// Extra environment variables. + #[serde(default)] + pub env: std::collections::HashMap, + /// Memory limit (e.g. "512m", "1g"). + pub memory_limit: Option, + /// CPU limit (e.g. "1.0", "0.5"). + pub cpu_limit: Option, + /// Shell to run inside the container. + pub shell: Option, + /// User to run as inside the container. + pub user: Option, + /// 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, +} + +#[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 } diff --git a/crates/bascule-core/src/container.rs b/crates/bascule-core/src/container.rs new file mode 100644 index 0000000..e8671ce --- /dev/null +++ b/crates/bascule-core/src/container.rs @@ -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 { + 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 { + 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, + ) -> anyhow::Result { + 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(()) + } + } +} diff --git a/crates/bascule-core/src/handler.rs b/crates/bascule-core/src/handler.rs index c984212..f614a66 100644 --- a/crates/bascule-core/src/handler.rs +++ b/crates/bascule-core/src/handler.rs @@ -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>), Proxy(Arc>), + Container(Arc>), } /// 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 { + async fn auth_publickey(&mut self, user: &str, public_key: &key::PublicKey) -> Result { 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 { + async fn auth_password(&mut self, user: &str, password: &str) -> Result { 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, - _session: &mut Session, - ) -> Result { + async fn channel_open_session(&mut self, _channel: Channel, _session: &mut Session) -> Result { 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(()) diff --git a/crates/bascule-core/src/lib.rs b/crates/bascule-core/src/lib.rs index 0bc5f73..720b125 100644 --- a/crates/bascule-core/src/lib.rs +++ b/crates/bascule-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod config; +pub mod container; pub mod handler; pub mod hooks; pub mod proxy; diff --git a/images/dev/Dockerfile b/images/dev/Dockerfile new file mode 100644 index 0000000..c76d843 --- /dev/null +++ b/images/dev/Dockerfile @@ -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"] diff --git a/images/k8s-ops/Dockerfile b/images/k8s-ops/Dockerfile new file mode 100644 index 0000000..f3f45f3 --- /dev/null +++ b/images/k8s-ops/Dockerfile @@ -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"] diff --git a/images/minimal/Dockerfile b/images/minimal/Dockerfile new file mode 100644 index 0000000..3547585 --- /dev/null +++ b/images/minimal/Dockerfile @@ -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"] diff --git a/images/net-ops/Dockerfile b/images/net-ops/Dockerfile new file mode 100644 index 0000000..893c88f --- /dev/null +++ b/images/net-ops/Dockerfile @@ -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"]