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",
]
[[package]]
name = "cluelessh-sftp"
version = "0.1.0"
[[package]]
name = "cluelessh-sftp-proto"
version = "0.1.0"
dependencies = [
"cluelessh-transport",
]
[[package]]
name = "cluelessh-tokio"
version = "0.1.0"
@ -489,6 +500,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "cluelesshd-sftp-server"
version = "0.1.0"
dependencies = [
"eyre",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "cobs"
version = "0.2.3"

View file

@ -230,6 +230,11 @@ async fn handle_session_channel(
channel.send(ChannelOperationKind::Eof).await?;
channel.send(ChannelOperationKind::Close).await?;
}
ChannelRequest::Subsystem { want_reply, .. } => {
if want_reply {
channel.send(ChannelOperationKind::Failure).await?;
}
}
ChannelRequest::ExitStatus { .. } => {}
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"
[subsystem.sftp]
path = "../../target/debug/cluelesshd-sftp-server"
[net]
ip = "0.0.0.0"
port = 2223

View file

@ -1,12 +1,15 @@
use eyre::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr},
path::PathBuf,
};
use crate::Args;
// TODO: validate config and user nicer structs to consume it
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
@ -15,6 +18,8 @@ pub struct Config {
pub net: NetConfig,
pub auth: AuthConfig,
pub security: SecurityConfig,
#[serde(default)]
pub subsystem: HashMap<String, SubsystemConfig>,
}
#[derive(Clone, Serialize, Deserialize)]
@ -52,8 +57,21 @@ pub struct SecurityConfig {
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 {
pub fn find(args: &Args) -> Result<Self> {
pub fn load(args: &Args) -> Result<Self> {
let path = std::env::var("CLUELESSHD_CONFIG")
.map(PathBuf::from)
.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())
})?;
toml::from_str(&content)
.wrap_err_with(|| format!("invalid config file '{}'", path.display()))
let mut config: Config = toml::from_str(&content)
.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(()) => {
if want_reply {
self.channel.send(ChannelOperationKind::Success).await?;
@ -309,7 +309,7 @@ impl SessionState {
want_reply,
command,
} => match String::from_utf8(command) {
Ok(command) => match self.shell(Some(&command)).await {
Ok(command) => match self.shell(Some(command), None).await {
Ok(()) => {
if want_reply {
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 {
name,
value,
@ -394,11 +409,12 @@ impl SessionState {
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
.rpc_client
.shell(
shell_command.map(ToOwned::to_owned),
shell_command,
subsystem,
self.pty_term.clone(),
self.envs.clone(),
)

View file

@ -25,7 +25,7 @@ use eyre::{bail, eyre, Context, Result};
use rustix::fs::MemfdFlags;
use serde::{Deserialize, Serialize};
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info, warn};
use tracing::{ error, info, warn};
use tracing_subscriber::EnvFilter;
@ -51,10 +51,14 @@ fn main() -> eyre::Result<()> {
// Initial setup
let args = Args::parse();
let config = config::Config::find(&args)?;
let config = config::Config::load(&args)?;
setup_tracing(&config);
for (name, system) in &config.subsystem {
info!(%name, path = %system.path.display(), "Loaded subsystem");
}
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");
}
@ -197,7 +201,8 @@ async fn spawn_connection_child(
) -> Result<()> {
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();

View file

@ -41,6 +41,8 @@ use users::os::unix::UserExt;
use users::User;
use zeroize::Zeroizing;
use crate::config::Config;
#[derive(Debug, Serialize, Deserialize)]
enum Request {
/// 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.
pty_term: Option<String>,
command: Option<String>,
subsystem: Option<String>,
env: Vec<(String, String)>,
}
@ -162,17 +165,20 @@ pub struct Server {
host_keys: Vec<PlaintextPrivateKey>,
authenticated_user: Option<users::User>,
config: Config,
pty_user: Option<OwnedFd>,
shell_process: Option<Child>,
}
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")?;
Ok(Self {
server,
client,
config,
host_keys,
authenticated_user: None,
pty_user: None,
@ -366,13 +372,28 @@ impl Server {
}
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 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 {
cmd.arg("-c");
cmd.arg(shell_command);
}
};
cmd.env_clear();
let has_pty = req.pty_term.is_some();
@ -404,7 +425,7 @@ impl Server {
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()?;
@ -533,12 +554,14 @@ impl Client {
pub async fn shell(
&self,
command: Option<String>,
subsystem: Option<String>,
pty_term: Option<String>,
env: Vec<(String, String)>,
) -> Result<Vec<OwnedFd>> {
self.send_request(&Request::Shell(ShellRequest {
pty_term,
command,
subsystem,
env,
}))
.await?;

View file

@ -105,6 +105,11 @@ pub enum ChannelRequest {
command: Vec<u8>,
},
Subsystem {
want_reply: bool,
name: String,
},
Env {
want_reply: bool,
@ -471,6 +476,19 @@ impl ChannelsState {
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" => {
if !self.is_server {
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)
}
ChannelRequest::Exec { .. } => todo!("exec"),
ChannelRequest::Subsystem { .. } => todo!("subsystem"),
ChannelRequest::Env { .. } => todo!("env"),
ChannelRequest::ExitStatus { status } => {
Packet::new_msg_channel_request_exit_status(
@ -787,6 +806,7 @@ impl ChannelOperation {
ChannelRequest::PtyReq { .. } => "pty-req",
ChannelRequest::Shell { .. } => "shell",
ChannelRequest::Exec { .. } => "exec",
ChannelRequest::Subsystem { .. } => "subsystem",
ChannelRequest::Env { .. } => "env",
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);
}
}