From e3aace8537707aa1bfea1343cc98f377336991ca Mon Sep 17 00:00:00 2001 From: Albert Peschar Date: Fri, 24 Jul 2026 10:45:58 +0000 Subject: [PATCH 1/2] Add Unix socket listener support --- CHANGES.md | 5 ++ README.md | 6 ++- snare.conf.5 | 11 ++++- snare.conf.example | 1 + src/config.rs | 40 +++++++++++----- src/httpserver.rs | 110 ++++++++++++++++++++++++++++++++++++++----- tests/common/mod.rs | 65 ++++++++++++++++++++++++- tests/config.rs | 9 ++++ tests/unix_socket.rs | 44 +++++++++++++++++ 9 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 tests/unix_socket.rs diff --git a/CHANGES.md b/CHANGES.md index 654d257..3442e54 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,8 @@ +# Unreleased + +* Allow the HTTP server to listen on a Unix-domain socket. + + # snare 0.4.13 (2026-05-03) * Split "check queue" and "temporary failure" into two. Previously "there is diff --git a/README.md b/README.md index 4500e0c..6c26790 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ github { where: - * `ip-address` is either an IPv4 or IPv6 address and `port` a port on which an - HTTP server will listen. + * `listen` is either an IPv4 or IPv6 address plus a port on which the HTTP + server will listen, or an absolute path to a Unix-domain socket (for example + `listen = "/run/snare.sock";`). A Unix socket is useful when a reverse proxy + on the same machine is the only client that needs to connect to `snare`. * `cmd` is the command that will be executed when a webhook is received. In this case, `/path/to/prps` is a path to a directory where per-repo programs are stored. For a repository `repo` owned by `owner` the command: diff --git a/snare.conf.5 b/snare.conf.5 index d44470c..d170062 100644 --- a/snare.conf.5 +++ b/snare.conf.5 @@ -13,10 +13,10 @@ It consists of one or more top-level options and one GitHub block. The top-level options are: .Bl -tag -width Ds .It Sy listen = Qq Em address ; -is a mandatory address and port number to listen on. +is a mandatory network address or Unix-domain socket path to listen on. The format of .Em address -is either: +is one of: .Bl -tag -width -Ds .It x.x.x.x:port an IPv4 address and port. @@ -28,6 +28,13 @@ an IPv6 address and port. For example, .Ql [::]:8765 will listen on port 8765 for all IPv4 and IPv6 addresses. +.It /absolute/path +an absolute path at which to create a Unix-domain socket. +For example, +.Ql /run/snare.sock . +The path must not already exist when +.Nm +starts. .El .It Sy maxjobs = Em int ; is an optional non-zero positive integer specifying the maximum number of diff --git a/snare.conf.example b/snare.conf.example index fb2b32e..550be7a 100644 --- a/snare.conf.example +++ b/snare.conf.example @@ -2,6 +2,7 @@ // * An IPv4 address (e.g. "0.0.0.0:8011" listens on all IPv4 interfaces). // * An IPv6 address (e.g. "[::]:8011" listens on all IPv4 and all IPv6 // interfaces). +// * An absolute Unix-domain socket path (e.g. "/run/snare.sock"). listen = "0.0.0.0:8011"; github { diff --git a/src/config.rs b/src/config.rs index 01af0c3..ce2168c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,10 @@ -use std::{fs::read_to_string, net::SocketAddr, path::Path, process, str::FromStr}; +use std::{ + fs::read_to_string, + net::SocketAddr, + path::{Path, PathBuf}, + process, + str::FromStr, +}; use crypto_common::InvalidLength; use hmac::{Hmac, Mac}; @@ -18,8 +24,8 @@ lrlex_mod!("config.l"); lrpar_mod!("config.y"); pub struct Config { - /// The IP address/port on which to listen. - pub listen: SocketAddr, + /// The address on which to listen. + pub listen: ListenAddr, /// The maximum number of parallel jobs to run. pub maxjobs: usize, /// The GitHub block. @@ -28,6 +34,11 @@ pub struct Config { pub user: Option, } +pub enum ListenAddr { + Tcp(SocketAddr), + Unix(PathBuf), +} + impl Config { /// Create a `Config` from `path`, returning `Err(String)` (containing a human readable /// message) if it was unable to do so. @@ -74,14 +85,21 @@ impl Config { )); } let listen_str = unescape_str(lexer.span_str(span)); - match SocketAddr::from_str(&listen_str) { - Ok(l) => listen = Some(l), - Err(e) => { - return Err(error_at_span( - &lexer, - span, - &format!("Invalid listen address '{}': {}", listen_str, e), - )); + if Path::new(&listen_str).is_absolute() { + listen = Some(ListenAddr::Unix(PathBuf::from(listen_str))); + } else { + match SocketAddr::from_str(&listen_str) { + Ok(l) => listen = Some(ListenAddr::Tcp(l)), + Err(e) => { + return Err(error_at_span( + &lexer, + span, + &format!( + "Invalid listen address '{}': {}", + listen_str, e + ), + )); + } } } } diff --git a/src/httpserver.rs b/src/httpserver.rs index a4ff0e1..0ebdc8f 100644 --- a/src/httpserver.rs +++ b/src/httpserver.rs @@ -3,6 +3,7 @@ use std::{ error::Error, io::{BufRead, BufReader, Read, Write}, net::{Shutdown, TcpListener, TcpStream}, + os::unix::net::{UnixListener, UnixStream}, sync::{ atomic::{AtomicUsize, Ordering}, Arc, @@ -16,7 +17,7 @@ use percent_encoding::percent_decode; use secstr::SecStr; use sha2::Sha256; -use crate::{queue::QueueJob, Snare}; +use crate::{config::ListenAddr, queue::QueueJob, Snare}; /// How many connections to accept simultaneously? Limiting this number stops attackers from /// causing us to use too many resources. @@ -27,17 +28,105 @@ static NET_TIMEOUT: Duration = Duration::from_secs(10); /// to stop large numbers of requests causing us to run out of memory. static MAX_HTTP_BODY_SIZE: usize = 64 * 1024; +enum Listener { + Tcp(TcpListener), + Unix(UnixListener), +} + +impl Listener { + fn bind(addr: &ListenAddr) -> std::io::Result { + match addr { + ListenAddr::Tcp(addr) => TcpListener::bind(addr).map(Self::Tcp), + ListenAddr::Unix(path) => UnixListener::bind(path).map(Self::Unix), + } + } + + fn accept(&self) -> std::io::Result { + match self { + Self::Tcp(listener) => listener.accept().map(|(stream, _)| Stream::Tcp(stream)), + Self::Unix(listener) => listener.accept().map(|(stream, _)| Stream::Unix(stream)), + } + } +} + +enum Stream { + Tcp(TcpStream), + Unix(UnixStream), +} + +impl Stream { + fn set_read_timeout(&self, timeout: Option) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.set_read_timeout(timeout), + Self::Unix(stream) => stream.set_read_timeout(timeout), + } + } + + fn set_write_timeout(&self, timeout: Option) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.set_write_timeout(timeout), + Self::Unix(stream) => stream.set_write_timeout(timeout), + } + } + + fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.shutdown(how), + Self::Unix(stream) => stream.shutdown(how), + } + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Tcp(stream) => stream.read(buf), + Self::Unix(stream) => stream.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Self::Tcp(stream) => stream.write(buf), + Self::Unix(stream) => stream.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.flush(), + Self::Unix(stream) => stream.flush(), + } + } +} + pub(crate) fn serve(snare: Arc) -> Result<(), Box> { - let listener = TcpListener::bind(snare.conf.lock().unwrap().listen)?; + let listener = Listener::bind(&snare.conf.lock().unwrap().listen)?; #[cfg(feature = "_internal_testing")] { if let Ok(p) = std::env::var("SNARE_DEBUG_PORT_PATH") { - std::fs::write(p, &listener.local_addr().unwrap().port().to_string()).unwrap(); + let value = match &listener { + Listener::Tcp(listener) => listener.local_addr().unwrap().port().to_string(), + Listener::Unix(listener) => listener + .local_addr() + .unwrap() + .as_pathname() + .unwrap() + .to_string_lossy() + .into_owned(), + }; + std::fs::write(p, value).unwrap(); } } let active = Arc::new(AtomicUsize::new(0)); - for stream in listener.incoming().flatten() { + loop { + let stream = match listener.accept() { + Ok(stream) => stream, + Err(_) => continue, + }; // We want to keep a limit on how many threads are started concurrently, so that an // attacker can't DOS the machine. `active` keeps track of how many threads are (or are // just about to be) active. Since the common case is that we haven't hit the limit, we @@ -60,11 +149,10 @@ pub(crate) fn serve(snare: Arc) -> Result<(), Box> { active.fetch_sub(1, Ordering::Relaxed); }); } - Ok(()) } /// Try processing an HTTP request. -fn request(snare: &Arc, mut stream: TcpStream) { +fn request(snare: &Arc, mut stream: Stream) { match ( stream.set_read_timeout(Some(NET_TIMEOUT)), stream.set_write_timeout(Some(NET_TIMEOUT)), @@ -235,7 +323,7 @@ fn request(snare: &Arc, mut stream: TcpStream) { /// A very literal, and rather unforgiving, implementation of RFC2616 (HTTP/1.1), returning the URL /// of GET requests: returns `Err` for anything else. -fn parse_get(stream: &mut TcpStream) -> Result<(HashMap, Vec), Box> { +fn parse_get(stream: &mut Stream) -> Result<(HashMap, Vec), Box> { let mut rdr = BufReader::new(stream); let mut req_line = String::new(); rdr.read_line(&mut req_line)?; @@ -294,19 +382,19 @@ fn parse_get(stream: &mut TcpStream) -> Result<(HashMap, Vec Ok((headers_map, body)) } -fn http_200(mut stream: TcpStream) { +fn http_200(mut stream: Stream) { stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n").ok(); } -fn http_400(mut stream: TcpStream) { +fn http_400(mut stream: Stream) { stream.write_all(b"HTTP/1.1 400\r\n\r\n").ok(); } -fn http_401(mut stream: TcpStream) { +fn http_401(mut stream: Stream) { stream.write_all(b"HTTP/1.1 401\r\n\r\n").ok(); } -fn http_500(mut stream: TcpStream) { +fn http_500(mut stream: Stream) { stream.write_all(b"HTTP/1.1 500\r\n\r\n").ok(); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 43cf222..cc35200 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -8,8 +8,9 @@ use std::{ fs::read_to_string, io::{Read, Write}, net::{Shutdown, TcpStream}, - os::unix::process::ExitStatusExt, + os::unix::{net::UnixStream, process::ExitStatusExt}, panic::{catch_unwind, resume_unwind, RefUnwindSafe, UnwindSafe}, + path::Path, process::{Child, Stdio}, rc::Rc, thread::sleep, @@ -109,6 +110,68 @@ where } } +#[allow(dead_code)] +pub fn run_unix_success( + cfg: &str, + socket_path: &Path, + request: &str, +) -> Result<(), Box> { + let (mut sn, tp) = snare_command(cfg)?; + for i in 0..5 { + match sn.try_wait() { + Ok(None) => break, + _ => { + if i < 4 { + sleep(Duration::from_secs(1)) + } else { + panic!() + } + } + } + } + + let r = catch_unwind(|| { + assert_eq!( + read_to_string(tp.path()).unwrap(), + socket_path.to_str().unwrap() + ); + + let mut stream = UnixStream::connect(socket_path).unwrap(); + stream.write_all(request.as_bytes()).unwrap(); + stream.shutdown(Shutdown::Write).unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 200 OK"), + "Received HTTP response '{}'", + response + ); + }); + + kill(Pid::from_raw(sn.id().try_into().unwrap()), Signal::SIGTERM).unwrap(); + let exit_status = sn.wait_timeout(SNARE_WAIT_TIMEOUT); + if let Err(r) = r { + let mut stdout = String::new(); + let mut stderr = String::new(); + sn.stdout.as_mut().unwrap().read_to_string(&mut stdout).ok(); + sn.stderr.as_mut().unwrap().read_to_string(&mut stderr).ok(); + eprintln!("snare child process:\n\n stdout:\n{stdout}\n stderr:\n{stderr}"); + resume_unwind(r); + } + + match exit_status { + Err(e) => Err(e.into()), + Ok(Some(es)) => { + if let Some(Signal::SIGTERM) = es.signal().map(|x| Signal::try_from(x).unwrap()) { + Ok(()) + } else { + Err(format!("Expected successful exit but got '{es:?}'").into()) + } + } + Ok(None) => Err("timeout waiting for snare to exit".into()), + } +} + #[allow(dead_code)] pub fn run_preserver_success(cfg: &str) -> Result<(), Box> { let (mut sn, _tp) = snare_command(cfg)?; diff --git a/tests/config.rs b/tests/config.rs index 0bebbdd..607f484 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -26,3 +26,12 @@ github { }"#, ) } + +#[test] +fn relative_socket_path_is_invalid() -> Result<(), Box> { + run_preserver_error( + r#"listen = "snare.sock"; +github { +}"#, + ) +} diff --git a/tests/unix_socket.rs b/tests/unix_socket.rs new file mode 100644 index 0000000..b29b11d --- /dev/null +++ b/tests/unix_socket.rs @@ -0,0 +1,44 @@ +use std::error::Error; + +use tempfile::Builder; + +mod common; +use common::run_unix_success; + +#[test] +fn accepts_http_requests() -> Result<(), Box> { + let td = Builder::new().tempdir_in(env!("CARGO_TARGET_TMPDIR"))?; + let socket_path = td.path().join("snare.sock"); + let cfg = format!( + r#" + listen = "{}"; + github {{ + match ".*" {{ + cmd = "true"; + }} + }} + "#, + socket_path.display() + ); + let body = r#"{ + "repository": { + "owner": { + "login": "testuser" + }, + "name": "testrepo" + } +}"#; + let request = format!( + "POST /payload HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Length: {}\r\n\ + Content-Type: application/json\r\n\ + X-GitHub-Event: ping\r\n\ + \r\n\ + {}", + body.len(), + body + ); + + run_unix_success(&cfg, &socket_path, &request) +} From 2fa7c8b8bcfa87de7eab05f89bab476278872010 Mon Sep 17 00:00:00 2001 From: Albert Peschar Date: Fri, 24 Jul 2026 13:50:31 +0000 Subject: [PATCH 2/2] Use unix: prefix for Unix socket listeners --- README.md | 7 ++++--- snare.conf.5 | 6 +++--- snare.conf.example | 2 +- src/config.rs | 11 +++++++++-- tests/config.rs | 27 +++++++++++++++++++++++++-- tests/unix_socket.rs | 2 +- 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6c26790..18446e1 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,10 @@ github { where: * `listen` is either an IPv4 or IPv6 address plus a port on which the HTTP - server will listen, or an absolute path to a Unix-domain socket (for example - `listen = "/run/snare.sock";`). A Unix socket is useful when a reverse proxy - on the same machine is the only client that needs to connect to `snare`. + server will listen, or `unix:` followed by a path to a Unix-domain socket + (for example `listen = "unix:/run/snare.sock";`). A Unix socket is useful + when a reverse proxy on the same machine is the only client that needs to + connect to `snare`. * `cmd` is the command that will be executed when a webhook is received. In this case, `/path/to/prps` is a path to a directory where per-repo programs are stored. For a repository `repo` owned by `owner` the command: diff --git a/snare.conf.5 b/snare.conf.5 index d170062..5621885 100644 --- a/snare.conf.5 +++ b/snare.conf.5 @@ -28,10 +28,10 @@ an IPv6 address and port. For example, .Ql [::]:8765 will listen on port 8765 for all IPv4 and IPv6 addresses. -.It /absolute/path -an absolute path at which to create a Unix-domain socket. +.It unix:path +a path at which to create a Unix-domain socket. For example, -.Ql /run/snare.sock . +.Ql unix:/run/snare.sock . The path must not already exist when .Nm starts. diff --git a/snare.conf.example b/snare.conf.example index 550be7a..5ef89af 100644 --- a/snare.conf.example +++ b/snare.conf.example @@ -2,7 +2,7 @@ // * An IPv4 address (e.g. "0.0.0.0:8011" listens on all IPv4 interfaces). // * An IPv6 address (e.g. "[::]:8011" listens on all IPv4 and all IPv6 // interfaces). -// * An absolute Unix-domain socket path (e.g. "/run/snare.sock"). +// * "unix:" followed by a Unix-domain socket path (e.g. "unix:snare.sock"). listen = "0.0.0.0:8011"; github { diff --git a/src/config.rs b/src/config.rs index ce2168c..693c596 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,8 +85,15 @@ impl Config { )); } let listen_str = unescape_str(lexer.span_str(span)); - if Path::new(&listen_str).is_absolute() { - listen = Some(ListenAddr::Unix(PathBuf::from(listen_str))); + if let Some(path) = listen_str.strip_prefix("unix:") { + if path.is_empty() { + return Err(error_at_span( + &lexer, + span, + "A Unix listen socket path must not be empty", + )); + } + listen = Some(ListenAddr::Unix(PathBuf::from(path))); } else { match SocketAddr::from_str(&listen_str) { Ok(l) => listen = Some(ListenAddr::Tcp(l)), diff --git a/tests/config.rs b/tests/config.rs index 607f484..6ee568e 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,5 +1,7 @@ use std::error::Error; +use tempfile::Builder; + mod common; use common::{run_preserver_error, run_preserver_success}; @@ -28,9 +30,30 @@ github { } #[test] -fn relative_socket_path_is_invalid() -> Result<(), Box> { +fn relative_socket_path_is_valid() -> Result<(), Box> { + let td = Builder::new().tempdir_in(env!("CARGO_TARGET_TMPDIR"))?; + let socket_path = td.path().strip_prefix("/")?.join("snare.sock"); + run_preserver_success(&format!( + r#"listen = "unix:{}"; +github {{ +}}"#, + socket_path.display() + )) +} + +#[test] +fn empty_socket_path_is_invalid() -> Result<(), Box> { + run_preserver_error( + r#"listen = "unix:"; +github { +}"#, + ) +} + +#[test] +fn socket_path_without_unix_prefix_is_invalid() -> Result<(), Box> { run_preserver_error( - r#"listen = "snare.sock"; + r#"listen = "/run/snare.sock"; github { }"#, ) diff --git a/tests/unix_socket.rs b/tests/unix_socket.rs index b29b11d..9c64d37 100644 --- a/tests/unix_socket.rs +++ b/tests/unix_socket.rs @@ -11,7 +11,7 @@ fn accepts_http_requests() -> Result<(), Box> { let socket_path = td.path().join("snare.sock"); let cfg = format!( r#" - listen = "{}"; + listen = "unix:{}"; github {{ match ".*" {{ cmd = "true";