From ead368cc7edc6ce65094eba7c942f48412e3bf5e Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Sat, 11 Feb 2023 13:56:34 -0700 Subject: [PATCH] flesh out the smtp protocol conversation Can now receive envelope and data and then discard it all --- Cargo.lock | 76 ++++++++++ crates/kumod/Cargo.toml | 3 + crates/kumod/src/smtp_server.rs | 260 +++++++++++++++++++++++++++++++- 3 files changed, 333 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5cb497e..800bdf28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,32 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -75,6 +95,23 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "colored" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "errno" version = "0.2.8" @@ -102,6 +139,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.2.6" @@ -139,12 +185,30 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "k9" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ddb58b0079a063218472916af599f2753ccb40942cdaba9d1f3fefccef17a9" +dependencies = [ + "anyhow", + "colored", + "diff", + "lazy_static", + "libc", + "proc-macro2", + "regex", + "syn", + "term_size", +] + [[package]] name = "kumod" version = "0.1.0" dependencies = [ "anyhow", "clap", + "k9", "tokio", "tracing", "tracing-subscriber", @@ -338,6 +402,8 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] @@ -427,6 +493,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "termcolor" version = "1.2.0" diff --git a/crates/kumod/Cargo.toml b/crates/kumod/Cargo.toml index 42e42e91..22de89ea 100644 --- a/crates/kumod/Cargo.toml +++ b/crates/kumod/Cargo.toml @@ -11,3 +11,6 @@ clap = {version="4.1", features=["derive"]} tokio = {version="1.25", features=["full"]} tracing = "0.1" tracing-subscriber = {version="0.3", features=["env-filter"]} + +[dev-dependencies] +k9 = "0.11" diff --git a/crates/kumod/src/smtp_server.rs b/crates/kumod/src/smtp_server.rs index 9d1b0f4f..b6f6061d 100644 --- a/crates/kumod/src/smtp_server.rs +++ b/crates/kumod/src/smtp_server.rs @@ -9,6 +9,15 @@ use tracing::instrument; pub struct SmtpServer { reader: BufReader>, writer: BufWriter>, + state: Option, + said_hello: Option, +} + +#[derive(Debug)] +struct TransactionState { + sender: EnvelopeAddress, + recipients: Vec, + data: Vec, } impl SmtpServer { @@ -17,7 +26,12 @@ impl SmtpServer { let (reader, writer) = tokio::io::split(socket); let reader = tokio::io::BufReader::new(reader); let writer = tokio::io::BufWriter::new(writer); - let mut server = SmtpServer { reader, writer }; + let mut server = SmtpServer { + reader, + writer, + state: None, + said_hello: None, + }; server.process().await } @@ -37,15 +51,249 @@ impl SmtpServer { Ok(()) } + async fn read_line(&mut self) -> anyhow::Result { + let mut line = String::new(); + self.reader.read_line(&mut line).await?; + Ok(line) + } + #[instrument] async fn process(&mut self) -> anyhow::Result<()> { - self.write_message(220, "Greetings from KumoMTA\nW00t!\nYeah!") + self.write_message(220, "mail.example.com KumoMTA\nW00t!\nYeah!") .await?; loop { - let mut line = String::new(); - self.reader.read_line(&mut line).await?; - self.write_message(420, format!("You said:\n{line}")) - .await?; + let line = self.read_line().await?; + let line = line.trim_end(); + + match Command::parse(line) { + Err(err) => { + self.write_message(501, format!("Syntax error in command or arguments: {err}")) + .await?; + } + Ok(Command::Quit) => { + self.write_message(221, "So long, and thanks for all the fish!") + .await?; + return Ok(()); + } + Ok(Command::Ehlo(domain)) => { + // TODO: we are supposed to report extension commands in our EHLO + // response, but we don't have any yet. + self.write_message(250, format!("mail.example.com Hello {domain}")) + .await?; + self.said_hello.replace(domain); + } + Ok(Command::Helo(domain)) => { + self.write_message(250, format!("Hello {domain}!")).await?; + self.said_hello.replace(domain); + } + Ok(Command::Mail(address)) => { + if self.state.is_some() { + self.write_message(503, "MAIL FROM already issued; you must RSET first") + .await?; + continue; + } + self.write_message(250, format!("OK {address:?}")).await?; + self.state.replace(TransactionState { + sender: address, + recipients: vec![], + data: vec![], + }); + } + Ok(Command::Rcpt(address)) => { + if self.state.is_none() { + self.write_message(503, "MAIL FROM must be issued first") + .await?; + continue; + } + self.write_message(250, format!("OK {address:?}")).await?; + self.state + .as_mut() + .expect("checked state above") + .recipients + .push(address); + } + Ok(Command::Data) => { + if self.state.is_none() { + self.write_message(503, "MAIL FROM must be issued first") + .await?; + continue; + } + if self + .state + .as_ref() + .map(|s| s.recipients.is_empty()) + .unwrap_or(true) + { + self.write_message(503, "RCPT TO must be issued first") + .await?; + continue; + } + self.write_message(354, "Send body; end with CRLF.CRLF") + .await?; + + let mut data = vec![]; + + loop { + let line = self.read_line().await?; + if line == ".\r\n" { + break; + } + + let line = if line.starts_with('.') { + &line[1..] + } else { + &line + }; + + data.extend_from_slice(line.as_bytes()); + } + + self.state.as_mut().map(|state| state.data = data); + + tracing::trace!(?self.state); + + self.write_message(250, "OK TODO: insert queueid here") + .await?; + } + Ok(Command::Rset) => { + self.state.take(); + self.write_message(250, "Reset state").await?; + } + Ok(Command::Noop) => { + self.write_message(250, "the goggles do nothing").await?; + } + Ok(Command::Unknown(cmd)) => { + self.write_message(502, format!("Command unrecognized/unimplemented: {cmd}")) + .await?; + } + } } } } + +#[derive(Debug, Clone, PartialEq)] +pub enum EnvelopeAddress { + Null, + Mailbox { user: String, domain: String }, +} + +impl EnvelopeAddress { + fn parse(text: &str) -> anyhow::Result { + if text.is_empty() { + Ok(Self::Null) + } else { + let fields: Vec<&str> = text.split('@').collect(); + anyhow::ensure!(fields.len() == 2, "expected user@domain"); + // TODO: stronger validation of local part and domain + Ok(Self::Mailbox { + user: fields[0].to_string(), + domain: fields[1].to_string(), + }) + } + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Command { + Ehlo(String), + Helo(String), + Mail(EnvelopeAddress), + Rcpt(EnvelopeAddress), + Data, + Rset, + Noop, + Quit, + Unknown(String), +} + +impl Command { + fn parse(line: &str) -> anyhow::Result { + fn prefix_match(line: &str, candidate: &str) -> bool { + if line.len() < candidate.len() { + false + } else { + line[..candidate.len()].eq_ignore_ascii_case(candidate) + } + } + + fn extract_envelope(line: &str) -> anyhow::Result<(&str, &str)> { + if !line.starts_with('<') { + anyhow::bail!("expected <: {line:?}"); + } + let rangle = line + .bytes() + .position(|c| c == b'>') + .ok_or_else(|| anyhow::anyhow!("expected >: {line:?}"))?; + + Ok((&line[1..rangle], &line[rangle + 1..])) + } + + Ok(if line.eq_ignore_ascii_case("QUIT") { + Self::Quit + } else if line.eq_ignore_ascii_case("DATA") { + Self::Data + } else if line.eq_ignore_ascii_case("RSET") { + Self::Rset + } else if line.eq_ignore_ascii_case("NOOP") { + Self::Noop + } else if prefix_match(line, "EHLO ") { + Self::Ehlo(line[5..].to_string()) + } else if prefix_match(line, "HELO ") { + Self::Helo(line[5..].to_string()) + } else if prefix_match(line, "MAIL FROM:") { + let (address, _params) = extract_envelope(&line[10..])?; + // TODO: MAIL FROM can accept key/value parameters + Self::Mail(EnvelopeAddress::parse(address)?) + } else if prefix_match(line, "RCPT TO:") { + let (address, _params) = extract_envelope(&line[8..])?; + if address.is_empty() { + anyhow::bail!("Null sender not permitted as a recipient"); + } + Self::Rcpt(EnvelopeAddress::parse(address)?) + } else { + Self::Unknown(line.to_string()) + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use k9::assert_equal; + + #[test] + fn command_parser() { + assert_equal!(Command::parse("QUIT").unwrap(), Command::Quit); + assert_equal!(Command::parse("quit").unwrap(), Command::Quit); + assert_equal!( + Command::parse("quite").unwrap(), + Command::Unknown("quite".to_string()) + ); + assert_equal!( + Command::parse("flibble").unwrap(), + Command::Unknown("flibble".to_string()) + ); + assert_equal!( + Command::parse("MAIL From:<>").unwrap(), + Command::Mail(EnvelopeAddress::Null) + ); + assert_equal!( + Command::parse("MAIL From:").unwrap(), + Command::Mail(EnvelopeAddress::Mailbox { + user: "user".to_string(), + domain: "example.com".to_string() + }) + ); + assert_equal!( + Command::parse("rcpt to:<>").unwrap_err().to_string(), + "Null sender not permitted as a recipient" + ); + assert_equal!( + Command::parse("rcpt TO:").unwrap(), + Command::Rcpt(EnvelopeAddress::Mailbox { + user: "user".to_string(), + domain: "example.com".to_string() + }) + ); + } +}