🏡 index : ~doyle/pisshoff.git

author Jordan Doyle <jordan@doyle.la> 2023-07-10 0:23:14.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-07-10 0:44:37.0 +00:00:00
commit
76bbb3c268c1522e291d2e7282ff4a816825bb8c [patch]
tree
caf52fef3e18228bb7eacfd2c993df7d374e835b
parent
79086c378aaa0503e454e599560668051f17ffeb
download
76bbb3c268c1522e291d2e7282ff4a816825bb8c.tar.gz

Hack together SFTP/modern SCP auditing support



Diff

 Cargo.lock                            |  20 +-
 pisshoff-server/Cargo.toml            |   4 +-
 pisshoff-server/src/main.rs           |   1 +-
 pisshoff-server/src/server.rs         |  83 +++--
 pisshoff-server/src/subsystem/mod.rs  |  19 +-
 pisshoff-server/src/subsystem/sftp.rs | 618 +++++++++++++++++++++++++++++++++++-
 pisshoff-types/src/audit.rs           |  13 +-
 7 files changed, 741 insertions(+), 17 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index b7b9ab3..e213c04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -840,6 +840,12 @@ dependencies = [
]

[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"

[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -874,6 +880,16 @@ dependencies = [
]

[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
 "memchr",
 "minimal-lexical",
]

[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1038,18 +1054,22 @@ name = "pisshoff-server"
version = "0.1.0"
dependencies = [
 "anyhow",
 "async-trait",
 "bitflags 2.3.3",
 "bytes",
 "clap",
 "fastrand",
 "futures",
 "insta",
 "itertools",
 "nix",
 "nom",
 "parking_lot",
 "pisshoff-types",
 "serde",
 "serde_json",
 "shlex",
 "strum",
 "test-case",
 "thrussh",
 "thrussh-keys",
diff --git a/pisshoff-server/Cargo.toml b/pisshoff-server/Cargo.toml
index 700c0f4..3400422 100644
--- a/pisshoff-server/Cargo.toml
+++ b/pisshoff-server/Cargo.toml
@@ -9,15 +9,19 @@ edition = "2021"
pisshoff-types = { path = "../pisshoff-types" }

anyhow = "1.0"
async-trait = "0.1"
bitflags = "2.3"
bytes = "1.4"
clap = { version = "4.3", features = ["derive", "env", "cargo"] }
futures = "0.3"
parking_lot = "0.12"
fastrand = "1.9"
itertools = "0.10"
nom = "7.1"
nix = { version = "0.26", features = ["hostname"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
strum = { version = "0.24", features = ["derive"] }
shlex = "1.1"
thrussh = "0.34"
thrussh-keys = "0.22"
diff --git a/pisshoff-server/src/main.rs b/pisshoff-server/src/main.rs
index 71af86d..ae975d3 100644
--- a/pisshoff-server/src/main.rs
+++ b/pisshoff-server/src/main.rs
@@ -20,6 +20,7 @@ mod config;
mod file_system;
mod server;
mod state;
mod subsystem;

#[tokio::main]
async fn main() {
diff --git a/pisshoff-server/src/server.rs b/pisshoff-server/src/server.rs
index fdeb143..04109a2 100644
--- a/pisshoff-server/src/server.rs
+++ b/pisshoff-server/src/server.rs
@@ -1,16 +1,18 @@
use crate::audit::{
    ExecCommandEvent, SignalEvent, SubsystemRequestEvent, TcpIpForwardEvent, WindowAdjustedEvent,
    WindowChangeRequestEvent,
};
use crate::file_system::FileSystem;
use crate::{
    audit::{
        AuditLog, AuditLogAction, LoginAttemptEvent, OpenDirectTcpIpEvent, OpenX11Event,
        PtyRequestEvent, X11RequestEvent,
    },
    audit::{
        ExecCommandEvent, SignalEvent, SubsystemRequestEvent, TcpIpForwardEvent,
        WindowAdjustedEvent, WindowChangeRequestEvent,
    },
    command::run_command,
    config::Config,
    file_system::FileSystem,
    state::State,
    subsystem,
    subsystem::Subsystem as SubsystemTrait,
};
use futures::{
    future::{BoxFuture, InspectErr},
@@ -18,6 +20,7 @@ use futures::{
};
use std::{
    borrow::Cow,
    collections::HashMap,
    future::Future,
    net::SocketAddr,
    pin::Pin,
@@ -30,6 +33,7 @@ use thrussh::{
};
use thrussh_keys::key::PublicKey;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::Mutex;
use tracing::{debug, error, info, info_span, instrument::Instrumented, Instrument, Span};

pub static KEYBOARD_INTERACTIVE_PROMPT: &[(Cow<'static, str>, bool)] =
@@ -76,6 +80,7 @@ impl thrussh::server::Server for Server {
            },
            username: None,
            file_system: None,
            subsystem: HashMap::new(),
        }
    }
}
@@ -86,6 +91,7 @@ pub struct Connection {
    audit_log: AuditLog,
    username: Option<String>,
    file_system: Option<FileSystem>,
    subsystem: HashMap<ChannelId, Arc<Mutex<Subsystem>>>,
}

impl Connection {
@@ -244,13 +250,21 @@ impl thrussh::server::Handler for Connection {
        self.finished(session).boxed().wrap(Span::current())
    }

    fn channel_eof(self, channel: ChannelId, mut session: Session) -> Self::FutureUnit {
    fn channel_eof(mut self, channel: ChannelId, mut session: Session) -> Self::FutureUnit {
        let span = info_span!(parent: &self.span, "channel_eof");
        let _entered = span.enter();

        info!("In here");
        if self.subsystem.remove(&channel).is_some() {
            session.channel_success(channel);
        } else {
            session.channel_failure(channel);
        }

        if self.subsystem.is_empty() {
            session.exit_status_request(channel, 0);
            session.close(channel);
        }

        session.channel_success(channel);
        self.finished(session).boxed().wrap(Span::current())
    }

@@ -310,18 +324,33 @@ impl thrussh::server::Handler for Connection {
        let span = info_span!(parent: &self.span, "data");
        let _entered = span.enter();

        let data = shlex::split(String::from_utf8_lossy(data).as_ref());
        // TODO: don't unwrap
        let subsystem = self.subsystem.get(&channel).unwrap().clone();
        let data = data.to_vec();

        async move {
            if let Some(args) = data {
                run_command(&args, channel, &mut session, &mut self).await;
                self.audit_log
                    .push_action(AuditLogAction::ExecCommand(ExecCommandEvent {
                        args: Box::from(args),
                    }));
            let mut subsystem = subsystem.lock().await;

            match &mut *subsystem {
                Subsystem::Shell => {
                    let data = shlex::split(String::from_utf8_lossy(&data).as_ref());
                    if let Some(args) = data {
                        run_command(&args, channel, &mut session, &mut self).await;
                        self.audit_log
                            .push_action(AuditLogAction::ExecCommand(ExecCommandEvent {
                                args: Box::from(args),
                            }));
                    }

                    session.data(channel, SHELL_PROMPT.to_string().into());
                }
                Subsystem::Sftp(ref mut inner) => {
                    inner
                        .data(&mut self.audit_log, channel, &data, &mut session)
                        .await;
                }
            }

            session.data(channel, SHELL_PROMPT.to_string().into());
            self.finished(session).await
        }
        .boxed()
@@ -448,6 +477,8 @@ impl thrussh::server::Handler for Connection {
        self.audit_log.push_action(AuditLogAction::ShellRequested);

        session.data(channel, SHELL_PROMPT.to_string().into());
        self.subsystem
            .insert(channel, Arc::new(Mutex::new(Subsystem::Shell)));

        session.channel_success(channel);
        self.finished(session).boxed().wrap(Span::current())
@@ -497,7 +528,19 @@ impl thrussh::server::Handler for Connection {
                name: Box::from(name),
            }));

        session.channel_failure(channel);
        let subsystem = match name {
            subsystem::sftp::Sftp::NAME => Some(Subsystem::Sftp(subsystem::sftp::Sftp::default())),
            _ => None,
        };

        if let Some(subsystem) = subsystem {
            self.subsystem
                .insert(channel, Arc::new(Mutex::new(subsystem)));
            session.channel_success(channel);
        } else {
            session.channel_failure(channel);
        }

        self.finished(session).boxed().wrap(Span::current())
    }

@@ -594,6 +637,12 @@ impl Drop for Connection {
    }
}

#[derive(Debug, Clone)]
pub enum Subsystem {
    Shell,
    Sftp(subsystem::sftp::Sftp),
}

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/mod.rs b/pisshoff-server/src/subsystem/mod.rs
new file mode 100644
index 0000000..55e9bda
--- /dev/null
+++ b/pisshoff-server/src/subsystem/mod.rs
@@ -0,0 +1,19 @@
use async_trait::async_trait;
use pisshoff_types::audit::AuditLog;
use thrussh::server::Session;
use thrussh::ChannelId;

pub mod sftp;

#[async_trait]
pub trait Subsystem {
    const NAME: &'static str;

    async fn data(
        &mut self,
        audit_log: &mut AuditLog,
        channel: ChannelId,
        data: &[u8],
        session: &mut Session,
    );
}
diff --git a/pisshoff-server/src/subsystem/sftp.rs b/pisshoff-server/src/subsystem/sftp.rs
new file mode 100644
index 0000000..9c12069
--- /dev/null
+++ b/pisshoff-server/src/subsystem/sftp.rs
@@ -0,0 +1,618 @@
use crate::subsystem::Subsystem;
use async_trait::async_trait;
use nom::{
    bytes::complete::take,
    combinator::{map_res, opt},
    error::ErrorKind,
    number::complete::{be_u32, be_u64, be_u8},
    IResult,
};
use pisshoff_types::audit::{AuditLog, AuditLogAction, MkdirEvent, WriteFileEvent};
use std::{collections::HashMap, io::Write, mem::size_of, str::FromStr};
use strum::FromRepr;
use thrussh::{server::Session, ChannelId};
use tracing::{debug, error, trace, warn};
use uuid::Uuid;

// https://datatracker.ietf.org/doc/html/draft-ietf-secsh-filexfer-13
#[derive(Default, Clone, Debug)]
pub struct Sftp {
    open_files: HashMap<Uuid, String>,
    pending_data: bytes::BytesMut,
}

#[async_trait]
impl Subsystem for Sftp {
    const NAME: &'static str = "sftp";

    #[allow(clippy::too_many_lines)]
    async fn data(
        &mut self,
        audit_log: &mut AuditLog,
        channel: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) {
        self.pending_data.extend_from_slice(data);

        loop {
            let data = self.pending_data.split();

            let packet = match WirePacket::parse(&data) {
                Ok((rest, packet)) => {
                    self.pending_data.extend_from_slice(rest);
                    packet
                }
                Err(e) if e.is_incomplete() => {
                    self.pending_data.unsplit(data);
                    break;
                }
                Err(nom::Err::Error(nom::error::Error {
                    code: ErrorKind::Eof,
                    ..
                })) => {
                    self.pending_data.unsplit(data);
                    break;
                }
                Err(e) => {
                    error!("Bad SFTP packet {e:?}");
                    break;
                }
            };

            match packet.typ {
                PacketType::Init => {
                    // the version the client sent us is in `request_id`, lets just echo it back
                    // to them, bounded by the version of the rfc we developed this barebones
                    // implementation against
                    session.data(
                        channel,
                        WirePacket::new(PacketType::Version, packet.request_id.min(6), &[])
                            .to_bytes()
                            .into(),
                    );
                }
                PacketType::Stat | PacketType::Lstat => {
                    let (_data, stat) = StatPacket::parse(packet.data).unwrap();

                    trace!("SFTP stat packet: {stat:?}");

                    session.data(
                        channel,
                        StatusResponse {
                            code: StatusCode::NoSuchFile,
                            message: "No such file or directory",
                        }
                        .to_packet(packet.request_id)
                        .into(),
                    );
                }
                PacketType::Open => {
                    let (_data, open) = OpenPacket::parse(packet.data).unwrap();

                    trace!("SFTP open packet: {open:?}");

                    let uuid = Uuid::new_v4();
                    self.open_files.insert(uuid, open.path.to_string());

                    session.data(
                        channel,
                        HandleResponse(uuid).to_packet(packet.request_id).into(),
                    );
                }
                PacketType::FSetStat | PacketType::SetStat => {
                    let (_data, set_stat) = FSetStatPacket::parse(packet.data).unwrap();

                    trace!("SFTP fsetstat packet: {set_stat:?}");

                    session.data(
                        channel,
                        StatusResponse {
                            code: StatusCode::Ok,
                            message: "",
                        }
                        .to_packet(packet.request_id)
                        .into(),
                    );
                }
                PacketType::Write => {
                    let (_data, write_packet) = WritePacket::parse(packet.data).unwrap();

                    let path = self
                        .open_files
                        .get(&Uuid::from_str(write_packet.handle).unwrap())
                        .unwrap();

                    debug!(
                        "Received write for {path} at offset {}: {:?}",
                        write_packet.offset, write_packet.data
                    );

                    audit_log.push_action(AuditLogAction::WriteFile(WriteFileEvent {
                        path: path.to_string().into_boxed_str(),
                        content: write_packet.data.to_string().into_boxed_str(),
                    }));

                    session.data(
                        channel,
                        StatusResponse {
                            code: StatusCode::Ok,
                            message: "",
                        }
                        .to_packet(packet.request_id)
                        .into(),
                    );
                }
                PacketType::Close => {
                    let (_data, close_packet) = ClosePacket::parse(packet.data).unwrap();

                    trace!("SFTP close packet: {close_packet:?}");

                    self.open_files
                        .remove(&Uuid::from_str(close_packet.handle).unwrap())
                        .unwrap();

                    session.data(
                        channel,
                        StatusResponse {
                            code: StatusCode::Ok,
                            message: "",
                        }
                        .to_packet(packet.request_id)
                        .into(),
                    );
                }
                PacketType::RealPath => {
                    let (_data, real_path) = RealPathPacket::parse(packet.data).unwrap();

                    trace!("SFTP realpath packet: {real_path:?}");

                    #[allow(clippy::wildcard_in_or_patterns)]
                    match real_path.control {
                        // SSH_FXP_REALPATH_STAT_ALWAYS
                        Some(2) => {
                            session.data(
                                channel,
                                StatusResponse {
                                    code: StatusCode::NoSuchFile,
                                    message: "No such file or directory",
                                }
                                .to_packet(packet.request_id)
                                .into(),
                            );
                        }
                        // SSH_FXP_REALPATH_NO_CHECK | SSH_FXP_REALPATH_STAT_IF
                        Some(0 | 1) | _ => {
                            session.data(
                                channel,
                                NameResponse {
                                    files: &[NameResponseFile {
                                        name: real_path.path,
                                        long_name: real_path.path,
                                        attrs: FileAttrs {
                                            typ: FileType::Unknown,
                                        },
                                    }],
                                }
                                .to_packet(packet.request_id)
                                .into(),
                            );
                        }
                    }
                }
                PacketType::Mkdir => {
                    let (_data, mkdir) = MkdirPacket::parse(packet.data).unwrap();

                    trace!("SFTP mkdir packet: {mkdir:?}");

                    audit_log.push_action(AuditLogAction::Mkdir(MkdirEvent {
                        path: mkdir.path.to_string().into_boxed_str(),
                    }));

                    session.data(
                        channel,
                        StatusResponse {
                            code: StatusCode::Ok,
                            message: "",
                        }
                        .to_packet(packet.request_id)
                        .into(),
                    );
                }
                _ => {
                    // TODO: return SSH_FX_OP_UNSUPPORTED
                    warn!("Unknown SFTP packet {packet:?}");
                }
            }
        }

        session.channel_success(channel);
        session.flush_pending(channel);
    }
}

fn take_length_delimited_string(rest: &[u8]) -> IResult<&[u8], &str> {
    let (rest, length) = be_u32(rest)?;
    map_res(take(length), std::str::from_utf8)(rest)
}

#[derive(Debug)]
struct MkdirPacket<'a> {
    path: &'a str,
    // TODO: fileattrs
}

impl<'a> MkdirPacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, path) = take_length_delimited_string(rest)?;

        Ok((rest, Self { path }))
    }
}

#[derive(Debug)]
struct RealPathPacket<'a> {
    path: &'a str,
    control: Option<u8>,
}

impl<'a> RealPathPacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, path) = take_length_delimited_string(rest)?;
        let (rest, control) = opt(be_u8)(rest)?;

        Ok((rest, Self { path, control }))
    }
}

#[derive(Debug)]
struct WritePacket<'a> {
    handle: &'a str,
    offset: u64,
    data: &'a str,
}

impl<'a> WritePacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, handle) = take_length_delimited_string(rest)?;
        let (rest, offset) = be_u64(rest)?;
        let (rest, data) = take_length_delimited_string(rest)?;

        Ok((
            rest,
            Self {
                handle,
                offset,
                data,
            },
        ))
    }
}

#[derive(Debug)]
struct ClosePacket<'a> {
    handle: &'a str,
}

impl<'a> ClosePacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, handle) = take_length_delimited_string(rest)?;

        Ok((rest, Self { handle }))
    }
}

#[derive(Debug)]
#[allow(dead_code)]
struct OpenPacket<'a> {
    path: &'a str,
    desired_access: u32,
    flags: u32,
}

impl<'a> OpenPacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, path) = take_length_delimited_string(rest)?;
        let (rest, desired_access) = be_u32(rest)?;
        let (rest, flags) = be_u32(rest)?;

        Ok((
            rest,
            Self {
                path,
                desired_access,
                flags,
            },
        ))
    }
}

#[derive(Debug)]
#[allow(dead_code)]
struct FSetStatPacket<'a> {
    handle: &'a str,
}

impl<'a> FSetStatPacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, handle) = take_length_delimited_string(rest)?;

        Ok((rest, Self { handle }))
    }
}

#[derive(Debug)]
#[allow(dead_code)]
struct StatPacket<'a> {
    path: &'a str,
    flags: u32,
}

impl<'a> StatPacket<'a> {
    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, path) = take_length_delimited_string(rest)?;
        let (rest, flags) = opt(be_u32)(rest)?;

        Ok((
            rest,
            Self {
                path,
                flags: flags.unwrap_or(0),
            },
        ))
    }
}

#[derive(Debug)]
struct WirePacket<'a> {
    length: u32,
    typ: PacketType,
    request_id: u32,
    data: &'a [u8],
}

impl<'a> WirePacket<'a> {
    fn new(typ: PacketType, request_id: u32, data: &'a [u8]) -> Self {
        Self {
            length: u32::try_from(size_of::<u8>() + size_of::<u32>() + data.len()).unwrap(),
            typ,
            request_id,
            data,
        }
    }

    fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(
            size_of::<u32>() + size_of::<u8>() + size_of::<u32>() + self.data.len(),
        );
        out.extend_from_slice(&self.length.to_be_bytes());
        out.push(self.typ as u8);
        out.extend_from_slice(&self.request_id.to_be_bytes());
        out.extend_from_slice(self.data);
        out
    }

    fn parse(rest: &'a [u8]) -> IResult<&'a [u8], Self> {
        let (rest, length) = be_u32(rest)?;
        let (rest, typ) = be_u8(rest)?;
        let (rest, request_id) = be_u32(rest)?;
        let (rest, data) = take(
            length - u32::try_from(size_of::<u8>() + size_of::<u32>()).unwrap_or(u32::MAX),
        )(rest)?;

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

        Ok((
            rest,
            Self {
                length,
                typ,
                request_id,
                data,
            },
        ))
    }
}

#[derive(Copy, Clone, Debug, FromRepr)]
#[repr(u8)]
pub enum PacketType {
    Init = 1,
    Version = 2,
    Open = 3,
    Close = 4,
    Read = 5,
    Write = 6,
    Lstat = 7,
    Fstat = 8,
    SetStat = 9,
    FSetStat = 10,
    OpenDir = 11,
    ReadDir = 12,
    Remove = 13,
    Mkdir = 14,
    Rmdir = 15,
    RealPath = 16,
    Stat = 17,
    Rename = 18,
    ReadLink = 19,
    Link = 21,
    Block = 22,
    Unblock = 23,
    Status = 101,
    Handle = 102,
    Data = 103,
    Name = 104,
    Attrs = 105,
    Extended = 200,
    ExtendedReply = 201,
}

pub struct StatusResponse<'a> {
    code: StatusCode,
    message: &'a str,
    // language_tag: &'a str,
}

impl Response for StatusResponse<'_> {
    const TYPE: PacketType = PacketType::Status;

    fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(size_of::<u32>() + size_of::<u32>() + self.message.len());
        out.extend_from_slice(&(self.code as u32).to_be_bytes());
        out.extend_from_slice(
            &u32::try_from(self.message.len())
                .unwrap_or(u32::MAX)
                .to_be_bytes(),
        );
        out.extend_from_slice(self.message.as_bytes());
        out
    }
}

pub struct HandleResponse(Uuid);

impl Response for HandleResponse {
    const TYPE: PacketType = PacketType::Handle;

    fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(size_of::<u32>() + 36);
        out.extend_from_slice(&36_u32.to_be_bytes());
        write!(out, "{}", self.0).unwrap();
        out
    }
}

pub struct NameResponse<'a> {
    files: &'a [NameResponseFile<'a>],
}

impl Response for NameResponse<'_> {
    const TYPE: PacketType = PacketType::Name;

    fn to_bytes(&self) -> Vec<u8> {
        // TODO: include nameresponsefile size
        let mut out = Vec::with_capacity(size_of::<u32>());
        out.extend_from_slice(
            &u32::try_from(self.files.len())
                .unwrap_or(u32::MAX)
                .to_be_bytes(),
        );

        for file in self.files {
            out.extend_from_slice(&file.to_bytes());
        }

        out.push(1);

        out
    }
}

pub struct NameResponseFile<'a> {
    name: &'a str,
    long_name: &'a str,
    attrs: FileAttrs,
}

impl NameResponseFile<'_> {
    fn to_bytes(&self) -> Vec<u8> {
        // TODO: include FileAttrs size
        let mut out = Vec::with_capacity(
            size_of::<u32>() + self.name.len() + size_of::<u32>() + self.long_name.len(),
        );
        out.extend_from_slice(
            &u32::try_from(self.name.len())
                .unwrap_or(u32::MAX)
                .to_be_bytes(),
        );
        out.extend_from_slice(self.name.as_bytes());
        out.extend_from_slice(
            &u32::try_from(self.long_name.len())
                .unwrap_or(u32::MAX)
                .to_be_bytes(),
        );
        out.extend_from_slice(self.long_name.as_bytes());
        out.extend_from_slice(&self.attrs.to_bytes());
        out
    }
}

#[derive(Copy, Clone, Debug)]
#[repr(u8)]
#[allow(dead_code)]
enum FileType {
    Regular = 1,
    Directory = 2,
    Symlink = 3,
    Special = 4,
    Unknown = 5,
    Socket = 6,
    CharDevice = 7,
    BlockDevice = 8,
    Fifo = 9,
}

#[derive(Copy, Clone, Debug)]
struct FileAttrs {
    typ: FileType,
}

impl FileAttrs {
    fn to_bytes(self) -> Vec<u8> {
        let mut out = Vec::with_capacity(size_of::<u32>() + size_of::<u8>());
        out.extend_from_slice(&0_u32.to_be_bytes());
        out.push(self.typ as u8);
        out
    }
}

#[derive(Copy, Clone, Debug)]
#[repr(u32)]
#[allow(dead_code)]
enum StatusCode {
    Ok = 0,
    Eof = 1,
    NoSuchFile = 2,
    PermissionDenied = 3,
    Failure = 4,
    BadMessage = 5,
    NoConnection = 6,
    ConnectionLost = 7,
    OpUnsupported = 8,
    InvalidHandle = 9,
    NoSuchPath = 10,
    FileAlreadyExists = 11,
    WriteProtect = 12,
    NoMedia = 13,
    NoSpaceOnFilesystem = 14,
    QuotaExceeded = 15,
    UnknownPrincipal = 16,
    LockConflict = 17,
    DirNotEmpty = 18,
    NotADirectory = 19,
    InvalidFilename = 20,
    LinkLoop = 21,
    CannotDelete = 22,
    InvalidParameter = 23,
    FileIsADirectory = 24,
    ByteRangeLockConflict = 25,
    ByteRangeLockRefused = 26,
    DeletePending = 27,
    FileCorrupt = 28,
    OwnerInvalid = 29,
    GroupInvalid = 30,
    NoMatchingByteRangeLock = 31,
}

trait Response {
    const TYPE: PacketType;

    fn to_bytes(&self) -> Vec<u8>;

    fn to_packet(&self, request_id: u32) -> Vec<u8> {
        WirePacket::new(Self::TYPE, request_id, &self.to_bytes()).to_bytes()
    }
}
diff --git a/pisshoff-types/src/audit.rs b/pisshoff-types/src/audit.rs
index 392d01e..834abbc 100644
--- a/pisshoff-types/src/audit.rs
+++ b/pisshoff-types/src/audit.rs
@@ -80,6 +80,19 @@ pub enum AuditLogAction {
    Signal(SignalEvent),
    TcpIpForward(TcpIpForwardEvent),
    CancelTcpIpForward(TcpIpForwardEvent),
    Mkdir(MkdirEvent),
    WriteFile(WriteFileEvent),
}

#[derive(Debug, Serialize, Deserialize)]
pub struct MkdirEvent {
    pub path: Box<str>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct WriteFileEvent {
    pub path: Box<str>,
    pub content: Box<str>,
}

#[derive(Debug, Serialize, Deserialize)]