use async_channel::{Receiver, Sender};
use async_io::{Async, Timer};
use bevy_remote::{
BrpBatch, BrpError, BrpMessage, BrpRequest, BrpResponse, BrpResult, error_codes,
};
use bevy_tasks::{IoTaskPool, Task, futures_lite::Stream};
use http_body_util::{BodyExt as _, Full, LengthLimitError, Limited};
use hyper::{
Request, Response,
body::{Body, Bytes, Frame, Incoming},
header::{CONTENT_TYPE, HeaderValue},
server::conn::http1,
service,
};
use nix::{
fcntl::{Flock, FlockArg},
sys::socket::{AddressFamily, Backlog, SockFlag, SockType, UnixAddr, bind, listen, socket},
unistd::geteuid,
};
use serde_json::Value;
use smol_hyper::rt::{FuturesIo, SmolTimer};
use std::{
convert::Infallible,
fs::{self, DirBuilder, File, OpenOptions},
io,
os::unix::{
fs::{DirBuilderExt, FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
net::{UnixListener, UnixStream},
},
path::{Path, PathBuf},
pin::Pin,
sync::mpsc,
task::{Context, Poll},
thread,
time::{Duration, Instant},
};
const DEFAULT_NAME: &str = "server.sock";
const PROBE: Duration = Duration::from_secs(1);
pub fn reject_retired_environment() -> Result<(), String> {
if std::env::var_os("FUX_ENDPOINT").is_some() {
return Err(
"FUX_ENDPOINT is no longer supported: fux serves only on a Unix domain socket. \
Unset FUX_ENDPOINT and, to choose a socket, set FUX_SOCKET to its absolute path"
.into(),
);
}
Ok(())
}
pub fn socket_path(flag: Option<&str>) -> Result<PathBuf, String> {
if let Some(flag) = flag {
return checked(flag, "--socket");
}
if let Some(value) = std::env::var_os("FUX_SOCKET") {
let value = value
.into_string()
.map_err(|_| "FUX_SOCKET is not valid UTF-8".to_string())?;
return checked(&value, "FUX_SOCKET");
}
for base in ["XDG_RUNTIME_DIR", "TMPDIR"] {
if let Some(value) = std::env::var_os(base) {
let value = value
.into_string()
.map_err(|_| format!("{base} is not valid UTF-8"))?;
if value.is_empty() {
return Err(format!(
"{base} is set but empty; set it to a directory or set FUX_SOCKET"
));
}
if !Path::new(&value).is_absolute() {
return Err(format!(
"{base} is {value:?}, not an absolute path; fix it or set FUX_SOCKET"
));
}
let path = Path::new(&value).join("fux").join(DEFAULT_NAME);
return checked(&path.to_string_lossy(), base);
}
}
Err(
"no socket location: neither XDG_RUNTIME_DIR nor TMPDIR is set; \
set FUX_SOCKET to an absolute socket path"
.into(),
)
}
pub fn max_path_bytes() -> usize {
let address: nix::libc::sockaddr_un = unsafe { std::mem::zeroed() };
std::mem::size_of_val(&address.sun_path) - 1
}
fn checked(value: &str, source: &str) -> Result<PathBuf, String> {
if value.is_empty() {
return Err(format!(
"{source} is empty; it must be an absolute socket path"
));
}
if value.contains("://") {
return Err(format!(
"{source} is {value:?}, which looks like a URL. fux no longer listens on TCP: \
{source} takes the absolute path of a Unix domain socket"
));
}
if value.contains('\0') {
return Err(format!("{source} contains a NUL byte"));
}
let path = PathBuf::from(value);
if !path.is_absolute() {
return Err(format!(
"{source} is {value:?}; it must be an absolute path"
));
}
if path.file_name().is_none() || path.parent().is_none() {
return Err(format!("{source} is {value:?}; it must name a socket file"));
}
let limit = max_path_bytes();
if value.len() > limit {
return Err(format!(
"{source} is {} bytes, longer than the {limit}-byte limit for a Unix socket \
path on this platform: {value}",
value.len()
));
}
Ok(path)
}
fn octal(mode: u32) -> String {
format!("{:04o}", mode & 0o7777)
}
fn private_directory(directory: &Path, create: bool) -> Result<(), String> {
let euid = geteuid().as_raw();
let shown = directory.display();
let above = directory
.parent()
.ok_or_else(|| format!("{shown} has no parent directory"))?;
let canonical = above
.canonicalize()
.map_err(|error| format!("socket directory parent {}: {error}", above.display()))?;
for ancestor in canonical.ancestors() {
let meta =
fs::metadata(ancestor).map_err(|error| format!("{}: {error}", ancestor.display()))?;
let mode = meta.permissions().mode();
let owned = meta.uid() == 0 || meta.uid() == euid;
let shared = mode & 0o022 != 0;
if !owned || (shared && mode & 0o1000 == 0) {
return Err(format!(
"{} (mode {}, owner uid {}) could be changed by another user, so the socket \
beneath it would not be private; choose another location with FUX_SOCKET",
ancestor.display(),
octal(mode),
meta.uid()
));
}
}
match fs::symlink_metadata(directory) {
Err(error) if error.kind() == io::ErrorKind::NotFound && create => {
match DirBuilder::new().mode(0o700).create(directory) {
Ok(()) => fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("{shown}: {error}"))?,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(format!("creating {shown}: {error}")),
}
}
Err(error) => return Err(format!("{shown}: {error}")),
Ok(_) => {}
}
let meta = fs::symlink_metadata(directory).map_err(|error| format!("{shown}: {error}"))?;
let mode = meta.permissions().mode();
if meta.file_type().is_symlink() {
return Err(format!(
"{shown} is a symbolic link; the socket directory must be a real directory"
));
}
if !meta.is_dir() || meta.uid() != euid || mode & 0o077 != 0 {
return Err(format!(
"{shown} must be a directory owned by you (uid {euid}) with mode 0700; it is {} \
with mode {} owned by uid {}. fux does not change it",
if meta.is_dir() {
"a directory"
} else {
"not a directory"
},
octal(mode),
meta.uid()
));
}
Ok(())
}
pub fn check_client_socket(path: &Path) -> Result<(), String> {
let meta = match fs::symlink_metadata(path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(format!(
"no fux server socket at {} (start one with `fux server`, or set FUX_SOCKET)",
path.display()
));
}
Err(error) => return Err(format!("{}: {error}", path.display())),
Ok(meta) => meta,
};
let directory = path
.parent()
.ok_or_else(|| format!("{} has no parent directory", path.display()))?;
private_directory(directory, false)?;
if !meta.file_type().is_socket() || meta.uid() != geteuid().as_raw() {
return Err(format!(
"{} is not a socket owned by you; refusing to connect",
path.display()
));
}
Ok(())
}
fn identity(meta: &fs::Metadata) -> (u64, u64) {
(meta.dev(), meta.ino())
}
pub struct Endpoint {
path: PathBuf,
socket: (u64, u64),
_lock: Flock<File>,
}
impl Endpoint {
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Endpoint {
fn drop(&mut self) {
if fs::symlink_metadata(&self.path).is_ok_and(|meta| identity(&meta) == self.socket) {
let _ = fs::remove_file(&self.path);
}
}
}
fn probe(path: &Path) -> io::Result<()> {
let (sender, receiver) = mpsc::channel();
let target = path.to_owned();
thread::spawn(move || {
let _ = sender.send(UnixStream::connect(target).map(drop));
});
receiver
.recv_timeout(PROBE)
.unwrap_or_else(|_| Err(io::Error::new(io::ErrorKind::TimedOut, "probe timed out")))
}
pub fn bind_socket(path: &Path) -> Result<(Endpoint, UnixListener), String> {
let shown = path.display().to_string();
let directory = path
.parent()
.ok_or_else(|| format!("{shown} has no parent directory"))?;
private_directory(directory, true)?;
let name = path
.file_name()
.ok_or_else(|| format!("{shown} names no file"))?;
let euid = geteuid().as_raw();
let foreign = |meta: &fs::Metadata| !meta.file_type().is_socket() || meta.uid() != euid;
let refused =
|| format!("{shown} exists and is not a socket owned by you; fux will not replace it");
if fs::symlink_metadata(path).is_ok_and(|meta| foreign(&meta)) {
return Err(refused());
}
let lock_path = directory.join(format!("{}.lock", name.to_string_lossy()));
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_CLOEXEC)
.open(&lock_path)
.map_err(|error| format!("{}: {error}", lock_path.display()))?;
let lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
if errno == nix::errno::Errno::EWOULDBLOCK {
format!("another fux server is already using {shown}")
} else {
format!("locking {}: {errno}", lock_path.display())
}
})?;
match fs::symlink_metadata(path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("{shown}: {error}")),
Ok(meta) if foreign(&meta) => return Err(refused()),
Ok(meta) => match probe(path) {
Ok(()) => {
return Err(format!(
"another fux server is already listening on {shown}"
));
}
Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {
if fs::symlink_metadata(path).is_ok_and(|now| identity(&now) == identity(&meta)) {
fs::remove_file(path)
.map_err(|error| format!("removing stale socket {shown}: {error}"))?;
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"cannot tell whether {shown} is in use ({error}); it was left in place"
));
}
},
}
let fd = socket(
AddressFamily::Unix,
SockType::Stream,
SockFlag::empty(),
None,
)
.map_err(|error| format!("socket: {error}"))?;
nix::fcntl::fcntl(
&fd,
nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC),
)
.map_err(|error| format!("socket: {error}"))?;
let address = UnixAddr::new(path).map_err(|error| format!("{shown}: {error}"))?;
bind(std::os::fd::AsRawFd::as_raw_fd(&fd), &address)
.map_err(|error| format!("binding {shown}: {error}"))?;
let created = fs::symlink_metadata(path).map_err(|error| format!("{shown}: {error}"))?;
let endpoint = Endpoint {
path: path.to_owned(),
socket: identity(&created),
_lock: lock,
};
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|error| format!("{shown}: {error}"))?;
listen(&fd, Backlog::new(128).map_err(|error| error.to_string())?)
.map_err(|error| format!("listening on {shown}: {error}"))?;
Ok((endpoint, UnixListener::from(fd)))
}
fn momentary(error: &io::Error) -> bool {
use nix::libc;
matches!(
error.raw_os_error(),
Some(libc::ECONNABORTED | libc::EINTR | libc::EAGAIN)
)
}
fn starved(error: &io::Error) -> bool {
use nix::libc;
matches!(
error.raw_os_error(),
Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM)
)
}
fn reserve() -> Option<File> {
File::open("/dev/null").ok()
}
const PRESSURE_REPORT: Duration = Duration::from_secs(5);
pub const MAX_BODY: usize = 4 << 20;
pub const MAX_BATCH: usize = 1024;
pub const MAX_BATCH_RESPONSE: usize = 8 << 20;
pub fn serve(
listener: UnixListener,
requests: Sender<BrpMessage>,
failed: impl FnOnce(String) + Send + 'static,
) -> Result<Task<()>, String> {
let listener = Async::new(listener).map_err(|error| format!("socket: {error}"))?;
Ok(IoTaskPool::get().spawn(async move {
let mut spare = reserve();
let mut pressure: Option<(Instant, Instant)> = None;
loop {
match listener.accept().await {
Ok((client, _)) => {
if let Some((since, _)) = pressure.take() {
bevy_log::error!(
"BRP socket accept recovered after {:.1?} of descriptor pressure.",
since.elapsed()
);
}
let requests = requests.clone();
IoTaskPool::get()
.spawn(async move {
let _ = http1::Builder::new()
.timer(SmolTimer::new())
.serve_connection(
FuturesIo::new(client),
service::service_fn(|request| batch(request, requests.clone())),
)
.await;
})
.detach();
}
Err(error) if starved(&error) => {
let now = Instant::now();
match &mut pressure {
None => {
bevy_log::error!(
"BRP socket accept is out of descriptors ({error}). \
New connections are being refused until this clears."
);
pressure = Some((now, now));
}
Some((since, reported)) if now.duration_since(*reported) >= PRESSURE_REPORT => {
bevy_log::warn!(
"BRP socket accept still out of descriptors after {:.1?} ({error}).",
since.elapsed()
);
*reported = now;
}
Some(_) => {}
}
if let Some(held) = spare.take() {
drop(held);
if let Ok((shed, _)) = listener.get_ref().accept() {
drop(shed);
}
spare = reserve();
}
Timer::after(Duration::from_millis(50)).await;
}
Err(error) if momentary(&error) => {
bevy_log::debug!("BRP socket accept: {error}");
}
Err(error) => {
failed(format!("BRP socket accept failed: {error}"));
return;
}
}
}
}))
}
enum Reply {
Complete(BrpResponse),
Stream(Watch),
}
pub enum Payload {
Complete(Full<Bytes>),
Stream(Watch),
}
impl Body for Payload {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
match self.get_mut() {
Payload::Complete(body) => Pin::new(body).poll_frame(cx),
Payload::Stream(body) => Pin::new(body).poll_frame(cx),
}
}
}
pub struct Watch {
id: Option<Value>,
results: Pin<Box<Receiver<BrpResult>>>,
}
fn json(value: &impl serde::Serialize) -> String {
serde_json::to_string(value).unwrap_or_else(|error| {
format!(
r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":{},"message":{}}}}}"#,
error_codes::INTERNAL_ERROR,
serde_json::Value::from(format!("response serialization: {error}"))
)
})
}
impl Body for Watch {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
match self.results.as_mut().poll_next(cx) {
Poll::Ready(Some(result)) => {
let response = BrpResponse::new(self.id.clone(), result);
let event = format!("data: {}\n\n", json(&response));
Poll::Ready(Some(Ok(Frame::data(Bytes::from(event)))))
}
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
fn is_end_stream(&self) -> bool {
self.results.is_closed()
}
}
fn invalid(id: Option<Value>, message: String) -> BrpResponse {
BrpResponse::new(
id,
Err(BrpError {
code: error_codes::INVALID_REQUEST,
message,
data: None,
}),
)
}
async fn batch(
request: Request<Incoming>,
requests: Sender<BrpMessage>,
) -> Result<Response<Payload>, Infallible> {
let body = match Limited::new(request.into_body(), MAX_BODY).collect().await {
Ok(body) => body.to_bytes(),
Err(error) => {
let message = if error.downcast_ref::<LengthLimitError>().is_some() {
format!("Request body exceeds the {MAX_BODY} byte limit")
} else {
error.to_string()
};
return Ok(complete(json(&invalid(None, message))));
}
};
let response = match serde_json::from_slice::<BrpBatch>(&body) {
Ok(BrpBatch::Single(request)) => match single(request, &requests).await {
Reply::Complete(response) => complete(json(&response)),
Reply::Stream(watch) => {
let mut response = Response::new(Payload::Stream(watch));
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response
}
},
Ok(BrpBatch::Batch(batch)) => {
if batch.len() > MAX_BATCH {
complete(json(&invalid(
None,
format!(
"Batch holds {} requests, more than the limit of {MAX_BATCH}",
batch.len()
),
)))
} else {
let mut responses: Vec<String> = Vec::with_capacity(batch.len());
let mut budget = MAX_BATCH_RESPONSE;
for request in batch {
let id = request.as_object().and_then(|map| map.get("id")).cloned();
let response = if budget == 0 {
invalid(
id,
format!(
"Batch reply exceeds the {MAX_BATCH_RESPONSE} byte limit; \
this request was not run"
),
)
} else if streaming(&request) {
invalid(
id,
"Streaming can not be used in batch requests".to_string(),
)
} else {
match single(request, &requests).await {
Reply::Complete(response) => response,
Reply::Stream(Watch { id, .. }) => invalid(
id,
"Streaming can not be used in batch requests".to_string(),
),
}
};
let serialized = json(&response);
budget = budget.saturating_sub(serialized.len());
responses.push(serialized);
}
complete(format!("[{}]", responses.join(",")))
}
}
Err(error) => complete(json(&invalid(None, error.to_string()))),
};
Ok(response)
}
fn complete(serialized: String) -> Response<Payload> {
let mut response = Response::new(Payload::Complete(Full::new(Bytes::from(serialized))));
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
response
}
fn streaming(request: &Value) -> bool {
request
.get("method")
.and_then(Value::as_str)
.is_some_and(|method| method.contains("+watch"))
}
async fn single(request: Value, requests: &Sender<BrpMessage>) -> Reply {
let id = request.as_object().and_then(|map| map.get("id")).cloned();
let request: BrpRequest = match serde_json::from_value(request) {
Ok(request) => request,
Err(error) => return Reply::Complete(invalid(id, error.to_string())),
};
let watch = request.method.contains("+watch");
let (sender, results) = async_channel::bounded(if watch { 8 } else { 1 });
let _ = requests
.send(BrpMessage {
method: request.method,
params: request.params,
sender,
})
.await;
if watch {
return Reply::Stream(Watch {
id: request.id,
results: Box::pin(results),
});
}
let result = results
.recv()
.await
.unwrap_or_else(|error| Err(BrpError::internal(error)));
Reply::Complete(BrpResponse::new(request.id, result))
}
#[cfg(test)]
mod tests;