bootstrap SFTP subsystem

This commit is contained in:
nora 2024-08-30 18:44:09 +02:00
parent 8de8204bc7
commit a9e2edc572
15 changed files with 205 additions and 17 deletions

20
Cargo.lock generated
View file

@ -422,6 +422,17 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "cluelessh-sftp"
version = "0.1.0"
[[package]]
name = "cluelessh-sftp-proto"
version = "0.1.0"
dependencies = [
"cluelessh-transport",
]
[[package]] [[package]]
name = "cluelessh-tokio" name = "cluelessh-tokio"
version = "0.1.0" version = "0.1.0"
@ -489,6 +500,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "cluelesshd-sftp-server"
version = "0.1.0"
dependencies = [
"eyre",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "cobs" name = "cobs"
version = "0.2.3" version = "0.2.3"

View file

@ -230,6 +230,11 @@ async fn handle_session_channel(
channel.send(ChannelOperationKind::Eof).await?; channel.send(ChannelOperationKind::Eof).await?;
channel.send(ChannelOperationKind::Close).await?; channel.send(ChannelOperationKind::Close).await?;
} }
ChannelRequest::Subsystem { want_reply, .. } => {
if want_reply {
channel.send(ChannelOperationKind::Failure).await?;
}
}
ChannelRequest::ExitStatus { .. } => {} ChannelRequest::ExitStatus { .. } => {}
ChannelRequest::Env { .. } => {} ChannelRequest::Env { .. } => {}
}; };

View file

@ -0,0 +1,12 @@
[package]
name = "cluelesshd-sftp-server"
version = "0.1.0"
edition = "2021"
[dependencies]
eyre.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
[lints]
workspace = true

View file

@ -0,0 +1,3 @@
# cluelesshd-sftp-server
SFTP server for cluelesshd.

View file

@ -0,0 +1,17 @@
use eyre::Result;
use tracing::info;
use tracing_subscriber::EnvFilter;
fn main() -> Result<()> {
let env_filter =
EnvFilter::try_from_env("SFTP_LOG").unwrap_or_else(|_| EnvFilter::new("debug"));
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(env_filter)
.init();
info!("mroooow!");
Ok(())
}

View file

@ -1,5 +1,9 @@
log_level = "info" log_level = "info"
[subsystem.sftp]
path = "../../target/debug/cluelesshd-sftp-server"
[net] [net]
ip = "0.0.0.0" ip = "0.0.0.0"
port = 2223 port = 2223

View file

@ -1,12 +1,15 @@
use eyre::{Context, Result}; use eyre::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr}, net::{IpAddr, Ipv4Addr},
path::PathBuf, path::PathBuf,
}; };
use crate::Args; use crate::Args;
// TODO: validate config and user nicer structs to consume it
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Config { pub struct Config {
@ -15,6 +18,8 @@ pub struct Config {
pub net: NetConfig, pub net: NetConfig,
pub auth: AuthConfig, pub auth: AuthConfig,
pub security: SecurityConfig, pub security: SecurityConfig,
#[serde(default)]
pub subsystem: HashMap<String, SubsystemConfig>,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@ -52,8 +57,21 @@ pub struct SecurityConfig {
pub experimental_seccomp: bool, pub experimental_seccomp: bool,
} }
/// Add arbitrary subsystems.
/// # Subsystem Protocol
/// Every subsystem process gets spawned in the home directory of the user, as the user.
/// Several FDs are guaranteed to be open.
/// - stdin (0): data from the client channel
/// - stdout (1): data to the client channel
/// - stderr (2): data to the client channel extended stderr (used for debugging)
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubsystemConfig {
pub path: PathBuf,
}
impl Config { impl Config {
pub fn find(args: &Args) -> Result<Self> { pub fn load(args: &Args) -> Result<Self> {
let path = std::env::var("CLUELESSHD_CONFIG") let path = std::env::var("CLUELESSHD_CONFIG")
.map(PathBuf::from) .map(PathBuf::from)
.or(args.config.clone().ok_or(std::env::VarError::NotPresent)) .or(args.config.clone().ok_or(std::env::VarError::NotPresent))
@ -63,8 +81,19 @@ impl Config {
format!("failed to open config file '{}', refusing to start. you can change the config file path with the --config arg or the CLUELESSHD_CONFIG environment variable", path.display()) format!("failed to open config file '{}', refusing to start. you can change the config file path with the --config arg or the CLUELESSHD_CONFIG environment variable", path.display())
})?; })?;
toml::from_str(&content) let mut config: Config = toml::from_str(&content)
.wrap_err_with(|| format!("invalid config file '{}'", path.display())) .wrap_err_with(|| format!("invalid config file '{}'", path.display()))?;
for sub in config.subsystem.values_mut() {
sub.path = sub.path.canonicalize().wrap_err_with(|| {
format!(
"error canonicalizing subsystem path: {}",
sub.path.display()
)
})?;
}
Ok(config)
} }
} }

View file

@ -292,7 +292,7 @@ impl SessionState {
} }
} }
} }
ChannelRequest::Shell { want_reply } => match self.shell(None).await { ChannelRequest::Shell { want_reply } => match self.shell(None, None).await {
Ok(()) => { Ok(()) => {
if want_reply { if want_reply {
self.channel.send(ChannelOperationKind::Success).await?; self.channel.send(ChannelOperationKind::Success).await?;
@ -309,7 +309,7 @@ impl SessionState {
want_reply, want_reply,
command, command,
} => match String::from_utf8(command) { } => match String::from_utf8(command) {
Ok(command) => match self.shell(Some(&command)).await { Ok(command) => match self.shell(Some(command), None).await {
Ok(()) => { Ok(()) => {
if want_reply { if want_reply {
self.channel.send(ChannelOperationKind::Success).await?; self.channel.send(ChannelOperationKind::Success).await?;
@ -330,6 +330,21 @@ impl SessionState {
} }
} }
}, },
ChannelRequest::Subsystem { want_reply, name } => {
match self.shell(None, Some(name)).await {
Ok(()) => {
if want_reply {
self.channel.send(ChannelOperationKind::Success).await?;
}
}
Err(err) => {
debug!(%err, "Failed to spawn subsystem");
if want_reply {
self.channel.send(ChannelOperationKind::Failure).await?;
}
}
}
}
ChannelRequest::Env { ChannelRequest::Env {
name, name,
value, value,
@ -394,11 +409,12 @@ impl SessionState {
Ok(()) Ok(())
} }
async fn shell(&mut self, shell_command: Option<&str>) -> Result<()> { async fn shell(&mut self, shell_command: Option<String>, subsystem: Option<String>) -> Result<()> {
let mut fds = self let mut fds = self
.rpc_client .rpc_client
.shell( .shell(
shell_command.map(ToOwned::to_owned), shell_command,
subsystem,
self.pty_term.clone(), self.pty_term.clone(),
self.envs.clone(), self.envs.clone(),
) )

View file

@ -25,7 +25,7 @@ use eyre::{bail, eyre, Context, Result};
use rustix::fs::MemfdFlags; use rustix::fs::MemfdFlags;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info, warn}; use tracing::{ error, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -51,10 +51,14 @@ fn main() -> eyre::Result<()> {
// Initial setup // Initial setup
let args = Args::parse(); let args = Args::parse();
let config = config::Config::find(&args)?; let config = config::Config::load(&args)?;
setup_tracing(&config); setup_tracing(&config);
for (name, system) in &config.subsystem {
info!(%name, path = %system.path.display(), "Loaded subsystem");
}
if !rustix::process::getuid().is_root() { if !rustix::process::getuid().is_root() {
warn!("Daemon not started as root. This disables several security mitigations and permits logging in as any other user"); warn!("Daemon not started as root. This disables several security mitigations and permits logging in as any other user");
} }
@ -197,7 +201,8 @@ async fn spawn_connection_child(
) -> Result<()> { ) -> Result<()> {
let stream_fd = stream.as_raw_fd(); let stream_fd = stream.as_raw_fd();
let mut rpc_server = rpc::Server::new(host_keys).wrap_err("creating RPC server")?; let mut rpc_server =
rpc::Server::new(config.clone(), host_keys).wrap_err("creating RPC server")?;
let rpc_client_fd = rpc_server.client_fd().as_raw_fd(); let rpc_client_fd = rpc_server.client_fd().as_raw_fd();

View file

@ -41,6 +41,8 @@ use users::os::unix::UserExt;
use users::User; use users::User;
use zeroize::Zeroizing; use zeroize::Zeroizing;
use crate::config::Config;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
enum Request { enum Request {
/// Performs the key exchange by generating a private key, deriving the shared secret, /// Performs the key exchange by generating a private key, deriving the shared secret,
@ -136,6 +138,7 @@ struct ShellRequest {
/// Whether a PTY is used and if yes, the TERM env var. /// Whether a PTY is used and if yes, the TERM env var.
pty_term: Option<String>, pty_term: Option<String>,
command: Option<String>, command: Option<String>,
subsystem: Option<String>,
env: Vec<(String, String)>, env: Vec<(String, String)>,
} }
@ -162,17 +165,20 @@ pub struct Server {
host_keys: Vec<PlaintextPrivateKey>, host_keys: Vec<PlaintextPrivateKey>,
authenticated_user: Option<users::User>, authenticated_user: Option<users::User>,
config: Config,
pty_user: Option<OwnedFd>, pty_user: Option<OwnedFd>,
shell_process: Option<Child>, shell_process: Option<Child>,
} }
impl Server { impl Server {
pub fn new(host_keys: Vec<PlaintextPrivateKey>) -> Result<Self> { pub fn new(config: Config, host_keys: Vec<PlaintextPrivateKey>) -> Result<Self> {
let (server, client) = UnixDatagram::pair().wrap_err("creating socketpair")?; let (server, client) = UnixDatagram::pair().wrap_err("creating socketpair")?;
Ok(Self { Ok(Self {
server, server,
client, client,
config,
host_keys, host_keys,
authenticated_user: None, authenticated_user: None,
pty_user: None, pty_user: None,
@ -366,13 +372,28 @@ impl Server {
} }
async fn shell(&mut self, user: &User, req: ShellRequest) -> Result<Vec<OwnedFd>> { async fn shell(&mut self, user: &User, req: ShellRequest) -> Result<Vec<OwnedFd>> {
let subsystem = match req.subsystem.as_deref() {
Some(subsystem) => match self.config.subsystem.get(subsystem) {
Some(system) => Some(system.path.clone()),
None => bail!("unsupported subsystem: {subsystem}"),
},
None => None,
};
let shell = user.shell(); let shell = user.shell();
let mut cmd = Command::new(shell); let cmd_arg0 = subsystem.as_deref().unwrap_or(shell);
// TODO: the SSH RFC mentions subsystems going through shell... should we?
let mut cmd = Command::new(cmd_arg0);
if subsystem.is_none() {
if let Some(shell_command) = req.command { if let Some(shell_command) = req.command {
cmd.arg("-c"); cmd.arg("-c");
cmd.arg(shell_command); cmd.arg(shell_command);
} }
};
cmd.env_clear(); cmd.env_clear();
let has_pty = req.pty_term.is_some(); let has_pty = req.pty_term.is_some();
@ -404,7 +425,7 @@ impl Server {
cmd.env(k, v); cmd.env(k, v);
} }
debug!(cmd = %shell.display(), uid = %user.uid(), gid = %user.primary_group_id(), "Executing process"); debug!(cmd = %cmd_arg0.display(), uid = %user.uid(), gid = %user.primary_group_id(), "Executing process");
let mut shell = cmd.spawn()?; let mut shell = cmd.spawn()?;
@ -533,12 +554,14 @@ impl Client {
pub async fn shell( pub async fn shell(
&self, &self,
command: Option<String>, command: Option<String>,
subsystem: Option<String>,
pty_term: Option<String>, pty_term: Option<String>,
env: Vec<(String, String)>, env: Vec<(String, String)>,
) -> Result<Vec<OwnedFd>> { ) -> Result<Vec<OwnedFd>> {
self.send_request(&Request::Shell(ShellRequest { self.send_request(&Request::Shell(ShellRequest {
pty_term, pty_term,
command, command,
subsystem,
env, env,
})) }))
.await?; .await?;

View file

@ -105,6 +105,11 @@ pub enum ChannelRequest {
command: Vec<u8>, command: Vec<u8>,
}, },
Subsystem {
want_reply: bool,
name: String,
},
Env { Env {
want_reply: bool, want_reply: bool,
@ -471,6 +476,19 @@ impl ChannelsState {
command: command.to_owned(), command: command.to_owned(),
} }
} }
"subsystem" => {
if !self.is_server {
return Err(peer_error!("server tried to set environment var"));
}
let name = p.utf8_string()?;
info!(channel = %our_channel, %name, "Starting subsystem");
ChannelRequest::Subsystem {
want_reply,
name: name.to_owned(),
}
}
"env" => { "env" => {
if !self.is_server { if !self.is_server {
return Err(peer_error!("server tried to set environment var")); return Err(peer_error!("server tried to set environment var"));
@ -632,6 +650,7 @@ impl ChannelsState {
Packet::new_msg_channel_request_shell(peer, b"shell", want_reply) Packet::new_msg_channel_request_shell(peer, b"shell", want_reply)
} }
ChannelRequest::Exec { .. } => todo!("exec"), ChannelRequest::Exec { .. } => todo!("exec"),
ChannelRequest::Subsystem { .. } => todo!("subsystem"),
ChannelRequest::Env { .. } => todo!("env"), ChannelRequest::Env { .. } => todo!("env"),
ChannelRequest::ExitStatus { status } => { ChannelRequest::ExitStatus { status } => {
Packet::new_msg_channel_request_exit_status( Packet::new_msg_channel_request_exit_status(
@ -787,6 +806,7 @@ impl ChannelOperation {
ChannelRequest::PtyReq { .. } => "pty-req", ChannelRequest::PtyReq { .. } => "pty-req",
ChannelRequest::Shell { .. } => "shell", ChannelRequest::Shell { .. } => "shell",
ChannelRequest::Exec { .. } => "exec", ChannelRequest::Exec { .. } => "exec",
ChannelRequest::Subsystem { .. } => "subsystem",
ChannelRequest::Env { .. } => "env", ChannelRequest::Env { .. } => "env",
ChannelRequest::ExitStatus { .. } => "exit-status", ChannelRequest::ExitStatus { .. } => "exit-status",
}, },

View file

@ -0,0 +1,10 @@
[package]
name = "cluelessh-sftp-proto"
version = "0.1.0"
edition = "2021"
[dependencies]
cluelessh-transport = { path = "../cluelessh-transport" }
[lints]
workspace = true

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,9 @@
[package]
name = "cluelessh-sftp"
version = "0.1.0"
edition = "2021"
[dependencies]
[lints]
workspace = true

View file

@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}