Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ 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 `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:
Expand Down
11 changes: 9 additions & 2 deletions snare.conf.5
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 unix:path
a path at which to create a Unix-domain socket.
For example,
.Ql unix:/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
Expand Down
1 change: 1 addition & 0 deletions snare.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
// * "unix:" followed by a Unix-domain socket path (e.g. "unix:snare.sock").
listen = "0.0.0.0:8011";

github {
Expand Down
39 changes: 32 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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.
Expand All @@ -28,6 +34,11 @@ pub struct Config {
pub user: Option<String>,
}

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.
Expand Down Expand Up @@ -74,15 +85,29 @@ impl Config {
));
}
let listen_str = unescape_str(lexer.span_str(span));
match SocketAddr::from_str(&listen_str) {
Ok(l) => listen = Some(l),
Err(e) => {
if let Some(path) = listen_str.strip_prefix("unix:") {
if path.is_empty() {
return Err(error_at_span(
&lexer,
span,
&format!("Invalid listen address '{}': {}", listen_str, e),
"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)),
Err(e) => {
return Err(error_at_span(
&lexer,
span,
&format!(
"Invalid listen address '{}': {}",
listen_str, e
),
));
}
}
}
}
config_ast::TopLevelOption::MaxJobs(span) => {
Expand Down
110 changes: 99 additions & 11 deletions src/httpserver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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<Self> {
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<Stream> {
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<Duration>) -> 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<Duration>) -> 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<usize> {
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<usize> {
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<Snare>) -> Result<(), Box<dyn Error>> {
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
Expand All @@ -60,11 +149,10 @@ pub(crate) fn serve(snare: Arc<Snare>) -> Result<(), Box<dyn Error>> {
active.fetch_sub(1, Ordering::Relaxed);
});
}
Ok(())
}

/// Try processing an HTTP request.
fn request(snare: &Arc<Snare>, mut stream: TcpStream) {
fn request(snare: &Arc<Snare>, mut stream: Stream) {
match (
stream.set_read_timeout(Some(NET_TIMEOUT)),
stream.set_write_timeout(Some(NET_TIMEOUT)),
Expand Down Expand Up @@ -235,7 +323,7 @@ fn request(snare: &Arc<Snare>, 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<String, String>, Vec<u8>), Box<dyn Error>> {
fn parse_get(stream: &mut Stream) -> Result<(HashMap<String, String>, Vec<u8>), Box<dyn Error>> {
let mut rdr = BufReader::new(stream);
let mut req_line = String::new();
rdr.read_line(&mut req_line)?;
Expand Down Expand Up @@ -294,19 +382,19 @@ fn parse_get(stream: &mut TcpStream) -> Result<(HashMap<String, String>, Vec<u8>
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();
}

Expand Down
65 changes: 64 additions & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,6 +110,68 @@ where
}
}

#[allow(dead_code)]
pub fn run_unix_success(
cfg: &str,
socket_path: &Path,
request: &str,
) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
let (mut sn, _tp) = snare_command(cfg)?;
Expand Down
Loading