🏡 index : ~doyle/pisshoff.git

author Jordan Doyle <jordan@doyle.la> 2024-02-04 14:19:56.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2024-02-04 20:46:28.0 +00:00:00
commit
51d3c21a11c3999e5d3348a0613d3c6d008353ec [patch]
tree
ef36145725cfebf1f5208dddcf3061b566e27c5b
parent
62439b2ad955840f5899ec8e609caf6d73861d5b
download
51d3c21a11c3999e5d3348a0613d3c6d008353ec.tar.gz

Implement command and environment substituion in shell subsystem

This change also implements foundations for stream redirection as well as
pipes.

Diff

 Cargo.lock                                    | 114 +++++-
 pisshoff-server/Cargo.toml                    |   3 +-
 pisshoff-server/src/audit.rs                  |   2 +-
 pisshoff-server/src/command.rs                |  65 ++-
 pisshoff-server/src/command/echo.rs           |   9 +-
 pisshoff-server/src/command/exit.rs           |   2 +-
 pisshoff-server/src/command/ls.rs             |   2 +-
 pisshoff-server/src/server.rs                 |  61 ++-
 pisshoff-server/src/subsystem/sftp.rs         |   5 +-
 pisshoff-server/src/subsystem/shell.rs        | 152 ++++++-
 pisshoff-server/src/subsystem/shell/parser.rs | 574 +++++++++++++++++++++++++++-
 pisshoff-types/src/audit.rs                   |   1 +-
 12 files changed, 952 insertions(+), 38 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index c7cc857..4c9a6ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -86,6 +86,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"

[[package]]
name = "arrayvec"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"

[[package]]
name = "async-trait"
version = "0.1.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -97,6 +103,15 @@ dependencies = [
]

[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
 "num-traits",
]

[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -188,6 +203,15 @@ dependencies = [
]

[[package]]
name = "brownstone"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5839ee4f953e811bfdcf223f509cb2c6a3e1447959b0bff459405575bc17f22"
dependencies = [
 "arrayvec",
]

[[package]]
name = "byteorder"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -700,6 +724,12 @@ dependencies = [
]

[[package]]
name = "indent_write"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3"

[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -780,6 +810,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"

[[package]]
name = "joinery"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72167d68f5fce3b8655487b8038691a3c9984ee769590f93f2a631f4ad64e4f5"

[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -948,6 +984,19 @@ dependencies = [
]

[[package]]
name = "nom-supreme"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd3ae6c901f1959588759ff51c95d24b491ecb9ff91aa9c2ef4acc5b1dcab27"
dependencies = [
 "brownstone",
 "indent_write",
 "joinery",
 "memchr",
 "nom",
]

[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1119,6 +1168,7 @@ version = "0.1.0"
dependencies = [
 "anyhow",
 "async-trait",
 "atoi",
 "bitflags 2.3.3",
 "bytes",
 "clap",
@@ -1129,6 +1179,7 @@ dependencies = [
 "mockall",
 "nix",
 "nom",
 "nom-supreme",
 "parking_lot",
 "pisshoff-types",
 "serde",
@@ -1143,6 +1194,7 @@ dependencies = [
 "tracing",
 "tracing-subscriber",
 "uuid",
 "yoke",
]

[[package]]
@@ -1612,6 +1664,12 @@ dependencies = [
]

[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"

[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1684,6 +1742,17 @@ dependencies = [
]

[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.20",
]

[[package]]
name = "termtree"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2314,3 +2383,48 @@ dependencies = [
 "bit-vec",
 "num-bigint",
]

[[package]]
name = "yoke"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65e71b2e4f287f467794c671e2b8f8a5f3716b3c829079a1c44740148eff07e4"
dependencies = [
 "serde",
 "stable_deref_trait",
 "yoke-derive",
 "zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e6936f0cce458098a201c245a11bef556c6a0181129c7034d10d76d1ec3a2b8"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.20",
 "synstructure",
]

[[package]]
name = "zerofrom"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655b0814c5c0b19ade497851070c640773304939a6c0fd5f5fb43da0696d05b7"
dependencies = [
 "zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6a647510471d372f2e6c2e6b7219e44d8c574d24fdc11c610a61455782f18c3"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.20",
 "synstructure",
]
diff --git a/pisshoff-server/Cargo.toml b/pisshoff-server/Cargo.toml
index 78ba335..22471da 100644
--- a/pisshoff-server/Cargo.toml
+++ b/pisshoff-server/Cargo.toml
@@ -10,6 +10,7 @@ pisshoff-types = { path = "../pisshoff-types" }

anyhow = "1.0"
async-trait = "0.1"
atoi = "2.0"
bitflags = "2.3"
bytes = "1.4"
clap = { version = "4.3", features = ["derive", "env", "cargo"] }
@@ -18,6 +19,7 @@ parking_lot = "0.12"
fastrand = "1.9"
itertools = "0.10"
nom = "7.1"
nom-supreme = "0.8"
nix = { version = "0.26", features = ["hostname"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -30,6 +32,7 @@ toml = "0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.3", features = ["v4", "serde"] }
yoke = { version = "0.7", features = ["derive"] }

[dev-dependencies]
mockall = "0.11"
diff --git a/pisshoff-server/src/audit.rs b/pisshoff-server/src/audit.rs
index 7ce2055..7db53ac 100644
--- a/pisshoff-server/src/audit.rs
+++ b/pisshoff-server/src/audit.rs
@@ -50,7 +50,7 @@ pub fn start_audit_writer(
                _ = &mut shutdown_recv => {
                    shutdown = true;
                }
                _ = tokio::time::sleep(Duration::from_secs(5)), if !writer.buffer().is_empty() => {
                () = tokio::time::sleep(Duration::from_secs(5)), if !writer.buffer().is_empty() => {
                    debug!("Flushing audits to disk");
                    writer.flush().await?;
                }
diff --git a/pisshoff-server/src/command.rs b/pisshoff-server/src/command.rs
index b02d8c0..2ef082b 100644
--- a/pisshoff-server/src/command.rs
+++ b/pisshoff-server/src/command.rs
@@ -9,13 +9,17 @@ mod whoami;
use crate::server::{ConnectionState, ThrusshSession};
use async_trait::async_trait;
use itertools::Either;
use std::borrow::Cow;
use std::fmt::Debug;
use thrussh::{server::Session, ChannelId};
use thrussh::ChannelId;

#[derive(Debug)]
pub enum CommandResult<T> {
    /// Wait for stdin
    ReadStdin(T),
    /// Exit process
    Exit(u32),
    /// Close session
    Close(u32),
}

@@ -55,6 +59,34 @@ pub trait Command: Sized {
    ) -> CommandResult<Self>;
}

#[derive(PartialEq, Eq, Debug)]
pub struct PartialCommand<'a> {
    exec: Option<Cow<'a, [u8]>>,
    params: Vec<Cow<'a, [u8]>>,
}

impl<'a> PartialCommand<'a> {
    pub fn new(exec: Option<Cow<'a, [u8]>>, params: Vec<Cow<'a, [u8]>>) -> Self {
        Self { exec, params }
    }

    pub async fn into_concrete_command<S: ThrusshSession + Send>(
        self,
        connection: &mut ConnectionState,
        channel: ChannelId,
        session: &mut S,
    ) -> CommandResult<ConcreteCommand> {
        // TODO: make commands take byte slices
        let args = self
            .params
            .iter()
            .map(|v| String::from_utf8_lossy(v).to_string())
            .collect::<Vec<_>>();

        ConcreteCommand::new(connection, self.exec.as_deref(), &args, channel, session).await
    }
}

macro_rules! define_commands {
    ($($name:ident($ty:ty) = $command:expr),*) => {
        #[derive(Debug, Clone)]
@@ -63,35 +95,36 @@ macro_rules! define_commands {
        }

        impl ConcreteCommand {
            pub async fn new(
            pub async fn new<S: ThrusshSession + Send>(
                connection: &mut ConnectionState,
                exec: Option<&[u8]>,
                params: &[String],
                channel: ChannelId,
                session: &mut Session,
                session: &mut S,
            ) -> CommandResult<Self> {
                let Some(command) = params.get(0) else {
                let Some(command) = exec else {
                    return CommandResult::Exit(0);
                };

                match command.as_str() {
                    $($command => <$ty as Command>::new(connection, &params[1..], channel, session).await.map(Self::$name),)*
                match command {
                    $($command => <$ty as Command>::new(connection, &params, channel, session).await.map(Self::$name),)*
                    other => {
                        // TODO: fix stderr displaying out of order
                        session.data(
                            channel,
                            format!("bash: {other}: command not found\n").into(),
                            format!("bash: {}: command not found\n", String::from_utf8_lossy(other)).into(),
                        );
                        CommandResult::Exit(1)
                    }
                }
            }

            pub async fn stdin(
            pub async fn stdin<S: ThrusshSession + Send>(
                self,
                connection: &mut ConnectionState,
                channel: ChannelId,
                data: &[u8],
                session: &mut Session,
                session: &mut S,
            ) -> CommandResult<Self> {
                match self {
                    $(Self::$name(cmd) => {
@@ -107,13 +140,13 @@ macro_rules! define_commands {
}

define_commands! {
    Echo(echo::Echo) = "echo",
    Exit(exit::Exit) = "exit",
    Ls(ls::Ls) = "ls",
    Pwd(pwd::Pwd) = "pwd",
    Scp(scp::Scp) = "scp",
    Uname(uname::Uname) = "uname",
    Whoami(whoami::Whoami) = "whoami"
    Echo(echo::Echo) = b"echo",
    Exit(exit::Exit) = b"exit",
    Ls(ls::Ls) = b"ls",
    Pwd(pwd::Pwd) = b"pwd",
    Scp(scp::Scp) = b"scp",
    Uname(uname::Uname) = b"uname",
    Whoami(whoami::Whoami) = b"whoami"
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
diff --git a/pisshoff-server/src/command/echo.rs b/pisshoff-server/src/command/echo.rs
index 4986a6c..33e39f4 100644
--- a/pisshoff-server/src/command/echo.rs
+++ b/pisshoff-server/src/command/echo.rs
@@ -17,7 +17,12 @@ impl Command for Echo {
        channel: ChannelId,
        session: &mut S,
    ) -> CommandResult<Self> {
        session.data(channel, format!("{}\n", params.iter().join(" ")).into());
        let suffix = if session.redirected() { "" } else { "\n" };

        session.data(
            channel,
            format!("{}{suffix}", params.iter().join(" ")).into(),
        );

        CommandResult::Exit(0)
    }
@@ -58,6 +63,8 @@ mod test {
            .with(always(), eq_string(output))
            .returning(|_, _| ());

        session.expect_redirected().returning(|| false);

        let out = Echo::new(
            &mut ConnectionState::mock(),
            params
diff --git a/pisshoff-server/src/command/exit.rs b/pisshoff-server/src/command/exit.rs
index 620dd6a..aa01ea0 100644
--- a/pisshoff-server/src/command/exit.rs
+++ b/pisshoff-server/src/command/exit.rs
@@ -18,7 +18,7 @@ impl Command for Exit {
        _session: &mut S,
    ) -> CommandResult<Self> {
        let exit_status = params
            .get(0)
            .first()
            .map(String::as_str)
            .map_or(Ok(0), u32::from_str)
            .unwrap_or(2);
diff --git a/pisshoff-server/src/command/ls.rs b/pisshoff-server/src/command/ls.rs
index e51cbcf..81278bd 100644
--- a/pisshoff-server/src/command/ls.rs
+++ b/pisshoff-server/src/command/ls.rs
@@ -22,7 +22,7 @@ impl Command for Ls {
        } else if params.len() == 1 {
            connection
                .file_system()
                .ls(Some(params.get(0).unwrap()))
                .ls(Some(params.first().unwrap()))
                .join("  ")
        } else {
            let mut out = String::new();
diff --git a/pisshoff-server/src/server.rs b/pisshoff-server/src/server.rs
index 1663a0d..a3b7d61 100644
--- a/pisshoff-server/src/server.rs
+++ b/pisshoff-server/src/server.rs
@@ -78,6 +78,7 @@ impl thrussh::server::Server for Server {
                },
                username: None,
                file_system: None,
                environment: HashMap::new(),
            },
            subsystem: HashMap::new(),
        }
@@ -88,6 +89,7 @@ pub struct ConnectionState {
    audit_log: AuditLog,
    username: Option<String>,
    file_system: Option<FileSystem>,
    environment: HashMap<Cow<'static, [u8]>, Cow<'static, [u8]>>,
}

impl ConnectionState {
@@ -109,6 +111,7 @@ impl ConnectionState {
            },
            username: None,
            file_system: None,
            environment: HashMap::new(),
        }
    }
}
@@ -129,6 +132,10 @@ impl ConnectionState {
    pub fn audit_log(&mut self) -> &mut AuditLog {
        &mut self.audit_log
    }

    pub fn environment(&self) -> &HashMap<Cow<'static, [u8]>, Cow<'static, [u8]>> {
        &self.environment
    }
}

pub struct Connection {
@@ -673,7 +680,7 @@ impl Drop for Connection {
    }
}

#[derive(Debug, Clone)]
#[derive(Debug)]
pub enum Subsystem {
    Shell(subsystem::shell::Shell),
    Sftp(subsystem::sftp::Sftp),
@@ -682,6 +689,10 @@ pub enum Subsystem {
#[cfg_attr(test, mockall::automock)]
pub trait ThrusshSession {
    fn data(&mut self, channel: ChannelId, data: CryptoVec);

    fn redirected(&self) -> bool {
        false
    }
}

impl ThrusshSession for Session {
@@ -690,6 +701,54 @@ impl ThrusshSession for Session {
    }
}

impl ThrusshSession for &mut Session {
    fn data(&mut self, channel: ChannelId, data: CryptoVec) {
        Session::data(self, channel, data);
    }
}

pub enum EitherSession<A, B> {
    L(A),
    R(B),
}

impl<A: ThrusshSession, B: ThrusshSession> ThrusshSession for EitherSession<A, B> {
    fn data(&mut self, channel: ChannelId, data: CryptoVec) {
        match self {
            Self::L(a) => a.data(channel, data),
            Self::R(b) => b.data(channel, data),
        }
    }

    fn redirected(&self) -> bool {
        match self {
            Self::L(a) => a.redirected(),
            Self::R(b) => b.redirected(),
        }
    }
}

pub struct StdoutCaptureSession<'a> {
    /// Captured stdout
    out: &'a mut Vec<u8>,
}

impl<'a> StdoutCaptureSession<'a> {
    pub fn new(out: &'a mut Vec<u8>) -> Self {
        Self { out }
    }
}

impl ThrusshSession for StdoutCaptureSession<'_> {
    fn data(&mut self, _channel: ChannelId, data: CryptoVec) {
        self.out.extend_from_slice(data.as_ref());
    }

    fn redirected(&self) -> bool {
        true
    }
}

type HandlerResult<T> = Result<T, <Connection as thrussh::server::Handler>::Error>;
type HandlerFuture<T> = ServerFuture<
    <Connection as thrussh::server::Handler>::Error,
diff --git a/pisshoff-server/src/subsystem/sftp.rs b/pisshoff-server/src/subsystem/sftp.rs
index aaa6c7d..ff8208e 100644
--- a/pisshoff-server/src/subsystem/sftp.rs
+++ b/pisshoff-server/src/subsystem/sftp.rs
@@ -406,7 +406,10 @@ impl<'a> WirePacket<'a> {
        )(rest)?;

        let Some(typ) = PacketType::from_repr(typ) else {
           return Err(nom::Err::Failure(nom::error::Error::new(rest, nom::error::ErrorKind::Verify)));
            return Err(nom::Err::Failure(nom::error::Error::new(
                rest,
                nom::error::ErrorKind::Verify,
            )));
        };

        Ok((
diff --git a/pisshoff-server/src/subsystem/shell.rs b/pisshoff-server/src/subsystem/shell.rs
index 86b0059..a9d7047 100644
--- a/pisshoff-server/src/subsystem/shell.rs
+++ b/pisshoff-server/src/subsystem/shell.rs
@@ -1,15 +1,23 @@
mod parser;

use crate::{
    command::{CommandResult, ConcreteCommand},
    server::ConnectionState,
    subsystem::Subsystem,
    server::{ConnectionState, EitherSession, StdoutCaptureSession},
    subsystem::{
        shell::parser::{tokenize, IterState, ParsedPart},
        Subsystem,
    },
};
use async_trait::async_trait;
use pisshoff_types::audit::{AuditLogAction, ExecCommandEvent};
use thrussh::{server::Session, ChannelId};
use tracing::info;

pub const SHELL_PROMPT: &str = "bash-5.1$ ";

#[derive(Clone, Debug)]
type IResult<I, O> = nom::IResult<I, O, nom_supreme::error::ErrorTree<I>>;

#[derive(Debug)]
pub struct Shell {
    interactive: bool,
    state: State,
@@ -29,7 +37,7 @@ impl Shell {

    fn handle_command_result(
        &self,
        command_result: CommandResult<ConcreteCommand>,
        command_result: CommandResult<ExecutingCommand>,
    ) -> (State, bool) {
        match (command_result, self.interactive) {
            (CommandResult::ReadStdin(cmd), _) => (State::Running(cmd), true),
@@ -53,21 +61,30 @@ impl Subsystem for Shell {
        session: &mut Session,
    ) {
        loop {
            let (next, terminal) = match std::mem::take(&mut self.state) {
            let (next, end) = match std::mem::take(&mut self.state) {
                State::Prompt => {
                    let Some(args) = shlex::split(String::from_utf8_lossy(data).as_ref()) else {
                        return;
                    };

                    connection
                        .audit_log()
                        .push_action(AuditLogAction::ExecCommand(ExecCommandEvent {
                            args: Box::from(args.clone()),
                            args: Box::from(vec![String::from_utf8_lossy(data).to_string()]),
                        }));

                    self.handle_command_result(
                        ConcreteCommand::new(connection, &args, channel, session).await,
                    )
                    match tokenize(data) {
                        Ok((_unparsed, args)) => {
                            let cmd = parser::Iter::new(
                                args.into_iter().map(ParsedPart::into_owned).collect(),
                            );
                            self.handle_command_result(
                                ExecutingCommand::new(cmd, connection, channel, session).await,
                            )
                        }
                        Err(e) => {
                            // TODO
                            info!("Invalid syntax: {e}");
                            session.data(channel, "bash: syntax error\n".to_string().into());
                            (State::Prompt, true)
                        }
                    }
                }
                State::Running(command) => self
                    .handle_command_result(command.stdin(connection, channel, data, session).await),
@@ -84,7 +101,7 @@ impl Subsystem for Shell {

            self.state = next;

            if terminal {
            if end {
                break;
            }
        }
@@ -95,11 +112,114 @@ impl Subsystem for Shell {
    }
}

#[derive(Debug, Clone, Default)]
#[derive(Debug)]
pub struct ExecutingCommand {
    iter: parser::Iter<'static>,
    current: ConcreteCommand,
    buf: Option<Vec<u8>>,
}

impl ExecutingCommand {
    async fn new(
        iter: parser::Iter<'static>,
        connection: &mut ConnectionState,
        channel: ChannelId,
        session: &mut Session,
    ) -> CommandResult<Self> {
        Self::new_inner(Vec::new(), iter, connection, channel, session).await
    }

    async fn new_inner(
        mut buf: Vec<u8>,
        mut iter: parser::Iter<'static>,
        connection: &mut ConnectionState,
        channel: ChannelId,
        session: &mut Session,
    ) -> CommandResult<Self> {
        loop {
            let (has_next, current) = match iter.step(
                connection.environment(),
                Some(std::mem::take(&mut buf)).filter(|v| !v.is_empty()),
            ) {
                IterState::Expand(cmd) => (true, cmd),
                IterState::Ready(cmd) => (false, cmd),
            };

            let mut session = if has_next {
                EitherSession::L(StdoutCaptureSession::new(&mut buf))
            } else {
                EitherSession::R(&mut *session)
            };

            match (
                current
                    .into_concrete_command(connection, channel, &mut session)
                    .await,
                has_next,
            ) {
                (CommandResult::ReadStdin(cmd), has_next) => {
                    break CommandResult::ReadStdin(Self {
                        iter,
                        current: cmd,
                        buf: has_next.then_some(buf),
                    })
                }
                (CommandResult::Exit(_status), true) => {
                    continue;
                }
                (CommandResult::Exit(status), false) => {
                    break CommandResult::Exit(status);
                }
                (CommandResult::Close(status), _) => {
                    break CommandResult::Close(status);
                }
            }
        }
    }

    async fn stdin(
        mut self,
        connection: &mut ConnectionState,
        channel: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) -> CommandResult<Self> {
        let mut sess = if let Some(buf) = &mut self.buf {
            EitherSession::L(StdoutCaptureSession::new(buf))
        } else {
            EitherSession::R(&mut *session)
        };

        match self
            .current
            .stdin(connection, channel, data, &mut sess)
            .await
        {
            CommandResult::ReadStdin(cmd) => CommandResult::ReadStdin(Self {
                iter: self.iter,
                current: cmd,
                buf: self.buf,
            }),
            CommandResult::Exit(_) => {
                Self::new_inner(
                    self.buf.unwrap_or_default(),
                    self.iter,
                    connection,
                    channel,
                    session,
                )
                .await
            }
            CommandResult::Close(status) => CommandResult::Close(status),
        }
    }
}

#[derive(Debug, Default)]
enum State {
    #[default]
    Prompt,
    Running(ConcreteCommand),
    Running(ExecutingCommand),
    Exit(u32),
    Quit(u32),
}
diff --git a/pisshoff-server/src/subsystem/shell/parser.rs b/pisshoff-server/src/subsystem/shell/parser.rs
new file mode 100644
index 0000000..22b0202
--- /dev/null
+++ b/pisshoff-server/src/subsystem/shell/parser.rs
@@ -0,0 +1,574 @@
use crate::{command::PartialCommand, subsystem::shell::IResult};
use nom::{
    branch::alt,
    bytes::complete::{escaped_transform, is_not, tag, take, take_until, take_while1},
    character::complete::{alphanumeric1, char, digit0, digit1, multispace1},
    combinator::{cut, fail, map, map_opt, peek, value},
    error::context,
    multi::{fold_many0, many_till},
    sequence::{delimited, preceded},
    AsChar,
};
use std::{borrow::Cow, collections::HashMap};

#[derive(Debug, PartialEq, Eq)]
pub enum IterState<'a> {
    Expand(PartialCommand<'a>),
    Ready(PartialCommand<'a>),
}

#[derive(Debug)]
pub struct Iter<'a> {
    command: std::vec::IntoIter<ParsedPart<'a>>,
    expanding: Option<Box<Iter<'a>>>,
    stdio_out: [RedirectionTo<'a>; 2],
    exec: Option<Cow<'a, [u8]>>,
    params: Vec<Cow<'a, [u8]>>,
}

impl<'a> Iter<'a> {
    pub fn new(command: Vec<ParsedPart<'a>>) -> Self {
        Self {
            command: command.into_iter(),
            expanding: None,
            stdio_out: [
                RedirectionTo::Stdio(0), // stdout
                RedirectionTo::Stdio(1), // stderr
            ],
            exec: None,
            params: Vec::new(),
        }
    }
}

impl<'a> Iter<'a> {
    pub fn step(
        &mut self,
        env: &HashMap<Cow<'static, [u8]>, Cow<'static, [u8]>>,
        mut previous_out: Option<Vec<u8>>,
    ) -> IterState<'a> {
        loop {
            let out = if let Some(expanding) = &mut self.expanding {
                return match expanding.step(env, previous_out) {
                    IterState::Expand(cmd) => {
                        // inner command has to expand some parameters, yield back to
                        // the shell to execute it, and return `expanding` back to the
                        // state, so we feed the input back to it
                        IterState::Expand(cmd)
                    }
                    IterState::Ready(cmd) => {
                        // inner command is ready to be executed after expanding its,
                        // params, however it's _our_ expansion, so we'll rewrite its
                        // 'ready to an expand', but we won't replace it back into the
                        // state so the `previous_out` is written to our params
                        self.expanding = None;
                        IterState::Expand(cmd)
                    }
                };
            } else if let Some(arg) = previous_out.take() {
                // our `expanding` has completed, and we've received its output so lets
                // store it in our params
                Cow::Owned(arg)
            } else if let Some(arg) = self.command.next() {
                // traverse the command AST until we hit the next actionable part
                match arg {
                    ParsedPart::Break => {
                        // if we hit a break insert a new parameter to start writing into
                        if self.params.last().map_or(true, |v| !v.is_empty()) {
                            self.params.push(Cow::Borrowed(b""));
                        }
                        continue;
                    }
                    ParsedPart::String(data) => {
                        // push the string into our params
                        data
                    }
                    ParsedPart::Expansion(Expansion::Command(command)) => {
                        // command needs to be substituted so lets yield to it
                        self.expanding = Some(Box::new(Iter::new(command)));
                        continue;
                    }
                    ParsedPart::Expansion(Expansion::Variable(variable)) => {
                        // substitute environment variable in
                        env.get(&variable).cloned().unwrap_or(Cow::Borrowed(b""))
                    }
                    ParsedPart::Redirection(idx, target) => {
                        // store a stdio redirection
                        if let Some(out) = self.stdio_out.get_mut(usize::from(idx)) {
                            *out = target;
                        }
                        continue;
                    }
                }
            } else {
                // fully evaluated and ready to be executed
                return IterState::Ready(PartialCommand::new(
                    self.exec.clone(),
                    self.params.clone(),
                ));
            };

            if self.exec.is_none() {
                self.exec = Some(out);
            } else if let Some(lst) = self.params.last_mut() {
                lst.to_mut().extend_from_slice(&out);
            } else {
                self.params.push(out);
            }
        }
    }
}

#[derive(PartialEq, Eq, Debug)]
pub enum ParsedPart<'a> {
    Break,
    String(Cow<'a, [u8]>),
    Expansion(Expansion<'a>),
    Redirection(u8, RedirectionTo<'a>),
}

impl ParsedPart<'_> {
    pub fn into_owned(self) -> ParsedPart<'static> {
        match self {
            ParsedPart::Break => ParsedPart::Break,
            ParsedPart::String(s) => ParsedPart::String(Cow::Owned(s.into_owned())),
            ParsedPart::Expansion(e) => ParsedPart::Expansion(e.into_owned()),
            ParsedPart::Redirection(s, e) => ParsedPart::Redirection(s, e.into_owned()),
        }
    }
}

#[derive(PartialEq, Eq, Debug)]
pub enum RedirectionTo<'a> {
    Stdio(u8),
    File(Cow<'a, [u8]>),
}

impl RedirectionTo<'_> {
    pub fn into_owned(self) -> RedirectionTo<'static> {
        match self {
            RedirectionTo::Stdio(v) => RedirectionTo::Stdio(v),
            RedirectionTo::File(f) => RedirectionTo::File(Cow::Owned(f.into_owned())),
        }
    }
}

#[derive(PartialEq, Eq, Debug)]
pub enum Expansion<'a> {
    Variable(Cow<'a, [u8]>),
    Command(Vec<ParsedPart<'a>>),
}

impl Expansion<'_> {
    pub fn into_owned(self) -> Expansion<'static> {
        match self {
            Expansion::Variable(v) => Expansion::Variable(Cow::Owned(v.into_owned())),
            Expansion::Command(c) => {
                Expansion::Command(c.into_iter().map(ParsedPart::into_owned).collect())
            }
        }
    }
}

/// Parses a single command (including substitutions), a command is delimited by a `;`, `|` or `>`
pub fn tokenize(s: &[u8]) -> IResult<&[u8], Vec<ParsedPart<'_>>> {
    fold_many0(parse_string_part, Vec::new, |mut acc, res| {
        acc.extend(res);
        acc
    })(s)
}

fn parse_string_part(s: &[u8]) -> IResult<&[u8], Vec<ParsedPart<'_>>> {
    if s.is_empty() {
        return context("empty input", fail)(s);
    }

    alt((
        parse_double_quoted,
        map(
            alt((
                parse_redirection,
                map(multispace1, |_| ParsedPart::Break),
                map(parse_single_quoted, |r| {
                    ParsedPart::String(Cow::Borrowed(r))
                }),
                map(parse_expansion, ParsedPart::Expansion),
                map(parse_unquoted, |r| ParsedPart::String(Cow::Owned(r))),
            )),
            |r| vec![r],
        ),
    ))(s)
}

fn parse_redirection(s: &[u8]) -> IResult<&[u8], ParsedPart<'_>> {
    let (s, from) = map_opt(digit0, atoi)(s)?;
    let (s, _) = char('>')(s)?;
    let (s, to) = alt((
        map(
            preceded(char('&'), map_opt(digit1, atoi)),
            RedirectionTo::Stdio,
        ),
        map(alphanumeric1, |f| RedirectionTo::File(Cow::Borrowed(f))),
    ))(s)?;

    Ok((s, ParsedPart::Redirection(from, to)))
}

fn parse_unquoted(s: &[u8]) -> IResult<&[u8], Vec<u8>> {
    escaped_transform(
        is_not("\\\n \"'$`|>&();"),
        '\\',
        alt((value(b"".as_slice(), char('\n')), take(1_u8))),
    )(s)
}

fn parse_single_quoted(s: &[u8]) -> IResult<&[u8], &[u8]> {
    // no special chars in single quoted, so we just need to read ahead
    // until the end quote
    delimited(char('\''), take_until("'"), char('\''))(s)
}

fn parse_double_quoted(s: &[u8]) -> IResult<&[u8], Vec<ParsedPart<'_>>> {
    let escaped = escaped_transform(
        is_not("\\\"$`"),
        '\\',
        alt((
            value(b"\"".as_slice(), char('"')),
            value(b"\n".as_slice(), char('n')),
            value(b"\t".as_slice(), char('t')),
            value(b"$".as_slice(), char('$')),
            value(b"`".as_slice(), char('`')),
            value(b"\\".as_slice(), char('\\')),
        )),
    );

    let take_part = alt((
        map(escaped, |r| ParsedPart::String(Cow::Owned(r))),
        map(parse_expansion, ParsedPart::Expansion),
    ));

    delimited(
        char('"'),
        map(many_till(take_part, peek(char('"'))), |(r, _)| r),
        char('"'),
    )(s)
}

fn parse_expansion(s: &[u8]) -> IResult<&[u8], Expansion<'_>> {
    let dollar_expansion = alt((
        map(tag("$"), |f| Expansion::Variable(Cow::Borrowed(f))),
        map(
            delimited(
                char('('),
                cut(context("tokenize", tokenize)),
                cut(context("end brace", char(')'))),
            ),
            Expansion::Command,
        ),
        map(take_while1(|c: u8| c.is_alphanum() || c == b'_'), |f| {
            Expansion::Variable(Cow::Borrowed(f))
        }),
        map(
            // TODO: this should deal with bash variable expansion operators
            //  like `-` which allows for a rhs default is a var is unset
            delimited(
                char('{'),
                take_until("}"),
                cut(context("end brace", char('}'))),
            ),
            |f| Expansion::Variable(Cow::Borrowed(f)),
        ),
    ));

    alt((
        preceded(char('$'), dollar_expansion),
        map(
            delimited(char('`'), context("tokenize", tokenize), char('`')),
            Expansion::Command,
        ),
    ))(s)
}

fn atoi(v: &[u8]) -> Option<u8> {
    if v.is_empty() {
        Some(0)
    } else {
        atoi::atoi(v)
    }
}

#[cfg(test)]
mod test {
    mod iter {
        use crate::command::PartialCommand;
        use crate::server::ConnectionState;
        use crate::subsystem::shell::parser::{tokenize, Iter, IterState};
        use std::borrow::Cow;

        #[test]
        fn single_nested() {
            let (rest, s) = tokenize(b"echo $(echo hello) world!").unwrap();
            assert!(rest.is_empty());

            let state = ConnectionState::mock();
            let mut command = Iter::new(s.into());

            // once we step we should be requested to execute `echo hello` for subbing
            let step = command.step(state.environment(), None);
            assert_eq!(
                step,
                IterState::Expand(PartialCommand::new(
                    Some(Cow::Borrowed(b"echo")),
                    vec![Cow::Borrowed(b"hello")]
                ))
            );

            // step again with the supposed output of the command we were requested to execute
            // and we should receive the final command to execute
            let step = command.step(state.environment(), Some(b"hello".to_vec()));
            assert_eq!(
                step,
                IterState::Ready(PartialCommand::new(
                    Some(Cow::Borrowed(b"echo")),
                    vec![Cow::Borrowed(b"hello"), Cow::Borrowed(b"world!")]
                ))
            );
        }

        #[test]
        fn multi_nested() {
            let (rest, s) = tokenize(b"echo $(echo hello `echo the whole`) world!").unwrap();
            assert!(rest.is_empty());

            let state = ConnectionState::mock();
            let mut command = Iter::new(s.into());

            // once we step we should be requested to execute `echo the whole` for subbing
            let step = command.step(state.environment(), None);
            assert_eq!(
                step,
                IterState::Expand(PartialCommand::new(
                    Some(Cow::Borrowed(b"echo")),
                    vec![Cow::Borrowed(b"the"), Cow::Borrowed(b"whole")]
                ))
            );

            // once we step we should be requested to execute `echo hello` for subbing
            let step = command.step(state.environment(), Some(b"the whole".to_vec()));
            assert_eq!(
                step,
                IterState::Expand(PartialCommand::new(
                    Some(Cow::Borrowed(b"echo")),
                    vec![Cow::Borrowed(b"hello"), Cow::Borrowed(b"the whole")]
                ))
            );

            // step again with the supposed output of the command we were requested to execute
            // and we should receive the final command to execute
            let step = command.step(state.environment(), Some(b"hello the whole".to_vec()));
            assert_eq!(
                step,
                IterState::Ready(PartialCommand::new(
                    Some(Cow::Borrowed(b"echo")),
                    vec![Cow::Borrowed(b"hello the whole"), Cow::Borrowed(b"world!")]
                ))
            );
        }
    }

    mod parse_command {
        use crate::subsystem::shell::parser::{tokenize, Expansion, ParsedPart, RedirectionTo};
        use std::borrow::Cow;

        #[test]
        fn messed_up() {
            let (rest, s) = tokenize(b"echo    ${HI}'this' \"is a \\t${TEST}\"using'$(complex string)>|' $(echo parsing) for the hell of it;fin").unwrap();
            assert_eq!(rest, b";fin");
            assert_eq!(
                s,
                vec![
                    ParsedPart::String(Cow::Borrowed(b"echo")),
                    ParsedPart::Break,
                    ParsedPart::Expansion(Expansion::Variable(Cow::Borrowed(b"HI"))),
                    ParsedPart::String(Cow::Borrowed(b"this")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"is a \t")),
                    ParsedPart::Expansion(Expansion::Variable(Cow::Borrowed(b"TEST"))),
                    ParsedPart::String(Cow::Borrowed(b"using")),
                    ParsedPart::String(Cow::Borrowed(b"$(complex string)>|")),
                    ParsedPart::Break,
                    ParsedPart::Expansion(Expansion::Command(vec![
                        ParsedPart::String(Cow::Borrowed(b"echo")),
                        ParsedPart::Break,
                        ParsedPart::String(Cow::Borrowed(b"parsing")),
                    ])),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"for")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"the")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"hell")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"of")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"it")),
                ]
            );
        }

        #[test]
        fn parses_named_redirects() {
            let (rest, s) = tokenize(b"hello test 2>&1").unwrap();
            assert!(rest.is_empty(), "{}", String::from_utf8_lossy(rest));
            assert_eq!(
                s,
                vec![
                    ParsedPart::String(Cow::Borrowed(b"hello")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"test")),
                    ParsedPart::Break,
                    ParsedPart::Redirection(2, RedirectionTo::Stdio(1)),
                ]
            );
        }

        #[test]
        fn parses_unnamed_redirects() {
            let (rest, s) = tokenize(b"hello test >&1").unwrap();
            assert!(rest.is_empty(), "{}", String::from_utf8_lossy(rest));
            assert_eq!(
                s,
                vec![
                    ParsedPart::String(Cow::Borrowed(b"hello")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"test")),
                    ParsedPart::Break,
                    ParsedPart::Redirection(0, RedirectionTo::Stdio(1)),
                ]
            );
        }
    }

    mod parse_expansion {
        use crate::subsystem::shell::parser::{parse_expansion, Expansion, ParsedPart};
        use std::borrow::Cow;

        #[test]
        fn double_dollar() {
            let (rest, s) = parse_expansion(b"$$a").unwrap();
            assert_eq!(rest, b"a");
            assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"$")));
        }

        #[test]
        fn variable() {
            let (rest, s) = parse_expansion(b"$HELLO_WORLD").unwrap();
            assert!(rest.is_empty());
            assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"HELLO_WORLD")));
        }

        #[test]
        fn variable_split() {
            let (rest, s) = parse_expansion(b"$HELLO-WORLD").unwrap();
            assert_eq!(rest, b"-WORLD");
            assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"HELLO")));
        }

        #[test]
        fn braced_variable() {
            let (rest, s) = parse_expansion(b"${helloworld}").unwrap();
            assert!(rest.is_empty());
            assert_eq!(s, Expansion::Variable(Cow::Borrowed(b"helloworld")));
        }

        #[test]
        fn not_expansion() {
            parse_expansion(b"NOT_VARIABLE").expect_err("not variable");
        }

        #[test]
        fn nested() {
            let (rest, s) = parse_expansion(b"$(\'echo\' \'hello\')").unwrap();
            assert!(rest.is_empty(), "{rest:?}");
            assert_eq!(
                s,
                Expansion::Command(vec![
                    ParsedPart::String(Cow::Borrowed(b"echo")),
                    ParsedPart::Break,
                    ParsedPart::String(Cow::Borrowed(b"hello")),
                ])
            )
        }
    }

    mod parse_unquoted {
        use crate::subsystem::shell::parser::parse_unquoted;

        #[test]
        fn escape() {
            let (rest, s) =
                parse_unquoted(b"hello\\ \\world\\ \\thi\\ns\\ is\\ a\\ \\$test\\\n! dontparse")
                    .unwrap();
            assert_eq!(rest, b" dontparse", "{}", String::from_utf8_lossy(rest));
            assert_eq!(
                s,
                b"hello world thins is a $test!".to_vec(),
                "{}",
                String::from_utf8_lossy(&s)
            );
        }
    }

    mod parse_single_quoted {
        use crate::subsystem::shell::parser::parse_single_quoted;

        #[test]
        fn multi_quote() {
            let (rest, s) = parse_single_quoted(b"'hello''world'").unwrap();
            assert_eq!(rest, b"'world'");
            assert_eq!(s, b"hello");
        }
    }

    mod parse_double_quoted {
        use crate::subsystem::shell::parser::{parse_double_quoted, Expansion, ParsedPart};
        use std::borrow::Cow;

        #[test]
        fn with_expansion() {
            let (rest, s) = parse_double_quoted(b"\"hello world $('cat' 'test') test\"").unwrap();
            assert!(rest.is_empty());
            assert_eq!(
                s,
                vec![
                    ParsedPart::String(Cow::Borrowed(b"hello world ")),
                    ParsedPart::Expansion(Expansion::Command(vec![
                        ParsedPart::String(Cow::Borrowed(b"cat")),
                        ParsedPart::Break,
                        ParsedPart::String(Cow::Borrowed(b"test")),
                    ])),
                    ParsedPart::String(Cow::Borrowed(b" test")),
                ]
            )
        }

        #[test]
        fn with_expansion_escape() {
            let (rest, s) = parse_double_quoted(b"\"hello world \\$('cat' 'test') test\"").unwrap();
            assert!(rest.is_empty());
            assert_eq!(
                s,
                vec![ParsedPart::String(Cow::Borrowed(
                    b"hello world $('cat' 'test') test"
                ))]
            );
        }

        #[test]
        fn with_escape_code() {
            let (rest, s) = parse_double_quoted(b"\"hi\\nworld\"").unwrap();
            assert!(rest.is_empty());
            assert_eq!(s, vec![ParsedPart::String(Cow::Borrowed(b"hi\nworld"))]);
        }
    }
}
diff --git a/pisshoff-types/src/audit.rs b/pisshoff-types/src/audit.rs
index 99dcad0..27f3f59 100644
--- a/pisshoff-types/src/audit.rs
+++ b/pisshoff-types/src/audit.rs
@@ -38,6 +38,7 @@ impl Default for AuditLog {
    }
}

#[allow(clippy::missing_fields_in_debug)]
impl Debug for AuditLog {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("AuditLog")