summaryrefslogtreecommitdiffstats
path: root/ingress/src
diff options
context:
space:
mode:
Diffstat (limited to 'ingress/src')
-rw-r--r--ingress/src/main.rs245
1 files changed, 245 insertions, 0 deletions
diff --git a/ingress/src/main.rs b/ingress/src/main.rs
new file mode 100644
index 0000000..4f8dff6
--- /dev/null
+++ b/ingress/src/main.rs
@@ -0,0 +1,245 @@
+use anyhow::{bail, Context, Result};
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+use nym_sdk::mixnet::{MixnetClientBuilder, StoragePaths};
+use serde::Deserialize;
+use std::env;
+use std::path::PathBuf;
+use std::time::Duration;
+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
+use tokio_socks::tcp::Socks5Stream;
+
+const MAX_REQUEST_BYTES: usize = 128 * 1024;
+const MAX_PAYLOAD_BYTES: usize = 96 * 1024;
+const SMTP_TIMEOUT: Duration = Duration::from_secs(60);
+
+#[derive(Debug, Deserialize)]
+struct Envelope {
+ version: u8,
+ entry_address: String,
+ payload: String,
+}
+
+fn storage_paths() -> Result<StoragePaths> {
+ let path = env::var_os("YAMN_NYM_INGRESS_STORAGE")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("/var/lib/yamnweb/nym-ingress"));
+ StoragePaths::new_from_dir(&path).context("prepare Nym ingress storage")
+}
+
+fn allowed_entry(address: &str) -> bool {
+ env::var("YAMN_ALLOWED_ENTRY_ADDRESSES")
+ .ok()
+ .map(|list| list.split(',').any(|item| item.trim() == address))
+ .unwrap_or(false)
+}
+
+fn dry_run() -> bool {
+ matches!(
+ env::var("YAMN_DRY_RUN").as_deref(),
+ Ok("1") | Ok("true") | Ok("yes")
+ )
+}
+
+fn split_address(address: &str) -> Result<(&str, &str)> {
+ if address.len() > 320 || address.chars().any(|c| matches!(c, '\r' | '\n' | '\0' | ' ')) {
+ bail!("invalid entry address");
+ }
+ let (local, domain) = address
+ .rsplit_once('@')
+ .context("entry address has no domain")?;
+ if local.is_empty() || domain.is_empty() || domain.contains('@') {
+ bail!("invalid entry address");
+ }
+ Ok((local, domain))
+}
+
+fn validate_envelope(envelope: &Envelope) -> Result<(String, Vec<u8>)> {
+ if envelope.version != 1 {
+ bail!("unsupported envelope version");
+ }
+ split_address(&envelope.entry_address)?;
+ if !allowed_entry(&envelope.entry_address) {
+ bail!("entry address is not allowlisted");
+ }
+ let payload = BASE64
+ .decode(&envelope.payload)
+ .context("invalid base64 YAMN payload")?;
+ if payload.is_empty() || payload.len() > MAX_PAYLOAD_BYTES || payload.contains(&0) {
+ bail!("invalid YAMN payload size or contents");
+ }
+ Ok((envelope.entry_address.clone(), payload))
+}
+
+async fn smtp_read_response<R: AsyncRead + Unpin>(reader: &mut BufReader<R>) -> Result<u16> {
+ let mut line = String::new();
+ loop {
+ line.clear();
+ let read = reader.read_line(&mut line).await.context("read SMTP response")?;
+ if read == 0 {
+ bail!("SMTP connection closed");
+ }
+ if line.len() < 3 {
+ bail!("malformed SMTP response");
+ }
+ let parsed: u16 = line[..3].parse().context("invalid SMTP response code")?;
+ if line.as_bytes().get(3) != Some(&b'-') {
+ return Ok(parsed);
+ }
+ }
+}
+
+async fn smtp_command<S: AsyncRead + AsyncWrite + Unpin>(
+ reader: &mut BufReader<S>,
+ command: &[u8],
+ expected_class: u16,
+) -> Result<()> {
+ reader
+ .get_mut()
+ .write_all(command)
+ .await
+ .context("write SMTP command")?;
+ reader
+ .get_mut()
+ .flush()
+ .await
+ .context("flush SMTP command")?;
+ let code = smtp_read_response(reader).await?;
+ if code / 100 != expected_class {
+ bail!("SMTP command rejected with code {code}");
+ }
+ Ok(())
+}
+
+async fn deliver(entry_address: &str, payload: &[u8]) -> Result<()> {
+ let (_, host) = split_address(entry_address)?;
+ let proxy = env::var("YAMN_TOR_SOCKS").unwrap_or_else(|_| "127.0.0.1:9050".to_string());
+ let (proxy_host, proxy_port) = proxy
+ .rsplit_once(':')
+ .context("invalid YAMN_TOR_SOCKS")?;
+ let proxy_port: u16 = proxy_port.parse().context("invalid YAMN_TOR_SOCKS port")?;
+ let target = format!("{host}:25");
+ let stream = tokio::time::timeout(
+ SMTP_TIMEOUT,
+ Socks5Stream::connect((proxy_host, proxy_port), target.as_str()),
+ )
+ .await
+ .context("Tor SMTP connection timed out")?
+ .context("connect to YAMN SMTP entry through Tor")?;
+ let mut reader = BufReader::new(stream);
+ let greeting = smtp_read_response(&mut reader).await?;
+ if greeting / 100 != 2 {
+ bail!("YAMN SMTP greeting rejected with code {greeting}");
+ }
+
+ let from = env::var("YAMN_INGRESS_FROM").unwrap_or_else(|_| "<>".to_string());
+ if from.chars().any(|c| matches!(c, '\r' | '\n' | ' ')) {
+ bail!("invalid YAMN_INGRESS_FROM");
+ }
+ smtp_command(&mut reader, b"EHLO yamn-nym-ingress\r\n", 2).await?;
+ smtp_command(
+ &mut reader,
+ format!("MAIL FROM:{from}\r\n").as_bytes(),
+ 2,
+ )
+ .await?;
+ smtp_command(
+ &mut reader,
+ format!("RCPT TO:<{entry_address}>\r\n").as_bytes(),
+ 2,
+ )
+ .await?;
+ smtp_command(&mut reader, b"DATA\r\n", 3).await?;
+
+ for line in payload.split_inclusive(|byte| *byte == b'\n') {
+ if line.starts_with(b".") {
+ reader
+ .get_mut()
+ .write_all(b".")
+ .await
+ .context("dot-stuff SMTP payload")?;
+ }
+ reader
+ .get_mut()
+ .write_all(line)
+ .await
+ .context("write SMTP payload")?;
+ }
+ if !payload.ends_with(b"\n") {
+ reader
+ .get_mut()
+ .write_all(b"\r\n")
+ .await
+ .context("terminate SMTP payload")?;
+ }
+ reader
+ .get_mut()
+ .write_all(b".\r\n")
+ .await
+ .context("finish SMTP payload")?;
+ reader
+ .get_mut()
+ .flush()
+ .await
+ .context("flush SMTP payload")?;
+ let accepted = smtp_read_response(&mut reader).await?;
+ if accepted / 100 != 2 {
+ bail!("YAMN SMTP server rejected payload with code {accepted}");
+ }
+ let _ = smtp_command(&mut reader, b"QUIT\r\n", 2).await;
+ Ok(())
+}
+
+async fn run() -> Result<()> {
+ let disconnected = MixnetClientBuilder::new_with_default_storage(storage_paths()?)
+ .await
+ .context("prepare Nym client")?
+ .build()
+ .context("build Nym client")?;
+ let mut client = disconnected
+ .connect_to_mixnet()
+ .await
+ .context("connect ingress to Nym mixnet")?;
+ eprintln!("yamn-nym-ingress address: {}", client.nym_address());
+
+ while let Some(messages) = client.wait_for_messages().await {
+ for message in messages {
+ if message.message.len() > MAX_REQUEST_BYTES {
+ eprintln!("discarded oversized Nym message");
+ continue;
+ }
+ let envelope: Envelope = match serde_json::from_slice(&message.message) {
+ Ok(value) => value,
+ Err(_) => {
+ eprintln!("discarded malformed Nym envelope");
+ continue;
+ }
+ };
+ let (entry_address, payload) = match validate_envelope(&envelope) {
+ Ok(value) => value,
+ Err(error) => {
+ eprintln!("discarded invalid YAMN envelope: {error}");
+ continue;
+ }
+ };
+ if dry_run() {
+ eprintln!("accepted YAMN envelope in dry-run mode");
+ continue;
+ }
+ if let Err(error) = deliver(&entry_address, &payload).await {
+ eprintln!("YAMN delivery failed: {error:#}");
+ } else {
+ eprintln!("delivered YAMN envelope to allowlisted entry");
+ }
+ }
+ }
+ client.disconnect().await;
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() {
+ if let Err(error) = run().await {
+ eprintln!("yamn-nym-ingress: {error:#}");
+ std::process::exit(1);
+ }
+}