use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use bincode::{Decode, Encode};
use reqwest::Method;
use subtle::ConstantTimeEq;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::Notify;
use tokio::task::JoinSet;
use zeroize::Zeroizing;
use crate::access::{AccessLevel, access_denied, required_access_for};
use crate::error::{Error, Result};
use crate::transport::{BoxFuture, RawResponse, RequestExecutor, RequestSpec};
const MAX_REQUEST_FRAME: u32 = 64 * 1024;
const MAX_RESPONSE_FRAME: u32 = 1024 * 1024;
const FRAME_BODY_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_AUTH_ATTEMPTS: u32 = 5;
const TOKEN_BYTES: usize = 32;
const MSG_AUTH_FAILED: &str = "authentication failed: wrong or already-used token";
const MSG_AUTH_REQUIRED: &str = "authenticate first: present the agent's one-time token";
const MSG_ALREADY_AUTHENTICATED: &str = "already authenticated";
pub struct AuthToken(Zeroizing<String>);
impl AuthToken {
pub fn generate() -> Result<Self> {
let mut bytes = Zeroizing::new([0u8; TOKEN_BYTES]);
getrandom::fill(bytes.as_mut_slice())
.map_err(|e| Error::agent(format!("could not read OS randomness for token: {e}")))?;
Ok(Self(Zeroizing::new(
URL_SAFE_NO_PAD.encode(bytes.as_slice()),
)))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn verify(&self, candidate: &str) -> bool {
self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
}
}
impl std::fmt::Debug for AuthToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("AuthToken").field(&"<redacted>").finish()
}
}
#[derive(Encode, Decode)]
pub enum AgentRequest {
Authenticate(String),
Ping,
Execute(WireRequest),
}
impl std::fmt::Debug for AgentRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Authenticate(_) => f.write_str("Authenticate(<redacted>)"),
Self::Ping => f.write_str("Ping"),
Self::Execute(wire) => f.debug_tuple("Execute").field(wire).finish(),
}
}
}
#[derive(Debug, Encode, Decode)]
pub enum AgentResponse {
Authenticated(Capabilities),
Pong,
Executed(WireResponse),
Failed(String),
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct Capabilities {
pub base_url: String,
pub access: AccessLevel,
}
#[derive(Debug, Encode, Decode)]
pub struct WireRequest {
method: String,
path: String,
query: Vec<(String, String)>,
body: Option<Vec<u8>>,
requires_auth: bool,
}
#[derive(Debug, Encode, Decode)]
pub struct WireResponse {
status: u16,
retry_after_millis: Option<u64>,
body: Vec<u8>,
}
#[must_use]
pub fn default_socket_path() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR").map_or_else(
|| {
std::env::temp_dir()
.join("revolutx-agent")
.join("agent.sock")
},
|dir| PathBuf::from(dir).join("revolutx-agent.sock"),
)
}
async fn write_frame<T: Encode + Sync>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> {
let bytes = bincode::encode_to_vec(value, bincode::config::standard())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let len = u32::try_from(bytes.len()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "frame too large to encode")
})?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}
async fn read_frame_bytes(reader: &mut UnixStream, max_len: u32) -> std::io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
reader.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf);
if len > max_len {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"frame exceeds the maximum size",
));
}
let mut buf = vec![0u8; len as usize];
tokio::time::timeout(FRAME_BODY_TIMEOUT, reader.read_exact(&mut buf))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "frame body timed out"))??;
Ok(buf)
}
fn decode<T: Decode<()>>(bytes: &[u8]) -> std::io::Result<T> {
bincode::decode_from_slice(bytes, bincode::config::standard())
.map(|(value, _)| value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
#[derive(Debug)]
pub struct AgentExecutor {
conn: tokio::sync::Mutex<UnixStream>,
caps: OnceLock<Capabilities>,
authenticated: AtomicBool,
}
impl AgentExecutor {
pub async fn connect(socket_path: impl AsRef<Path>) -> Result<Self> {
let socket_path = socket_path.as_ref();
let stream = UnixStream::connect(socket_path).await.map_err(|e| {
Error::agent(format!(
"cannot connect to agent at {}: {e}",
socket_path.display()
))
})?;
Ok(Self {
conn: tokio::sync::Mutex::new(stream),
caps: OnceLock::new(),
authenticated: AtomicBool::new(false),
})
}
pub async fn authenticate(&self, token: &str) -> Result<()> {
match self
.round_trip(&AgentRequest::Authenticate(token.to_owned()))
.await?
{
AgentResponse::Authenticated(caps) => {
let _ = self.caps.set(caps);
self.authenticated.store(true, Ordering::Release);
Ok(())
}
AgentResponse::Failed(message) => Err(Error::agent(message)),
AgentResponse::Pong | AgentResponse::Executed(_) => Err(Error::agent(
"agent did not answer the authentication handshake",
)),
}
}
#[must_use]
pub fn is_session_authenticated(&self) -> bool {
self.authenticated.load(Ordering::Acquire)
}
#[must_use]
pub fn access(&self) -> AccessLevel {
self.caps
.get()
.map_or(AccessLevel::Market, |caps| caps.access)
}
pub async fn ping(&self) -> Result<()> {
match self.round_trip(&AgentRequest::Ping).await? {
AgentResponse::Pong => Ok(()),
AgentResponse::Failed(message) => Err(Error::agent(message)),
_ => Err(Error::agent("agent did not answer ping with Pong")),
}
}
async fn round_trip(&self, request: &AgentRequest) -> Result<AgentResponse> {
let bytes = {
let mut conn = self.conn.lock().await;
write_frame(&mut conn, request)
.await
.map_err(|e| Error::agent(format!("failed to send request to agent: {e}")))?;
read_frame_bytes(&mut conn, MAX_RESPONSE_FRAME)
.await
.map_err(|e| Error::agent(format!("failed to read agent response: {e}")))?
};
decode(&bytes).map_err(|e| Error::agent(format!("invalid agent response: {e}")))
}
}
impl RequestExecutor for AgentExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let wire = WireRequest {
method: request.method().as_str().to_owned(),
path: request.path().to_owned(),
query: request.query().to_vec(),
body: request.body().map(<[u8]>::to_vec),
requires_auth: request.requires_auth(),
};
Box::pin(async move {
match self.round_trip(&AgentRequest::Execute(wire)).await? {
AgentResponse::Executed(w) => Ok(RawResponse {
status: w.status,
retry_after: w.retry_after_millis.map(Duration::from_millis),
body: w.body,
}),
AgentResponse::Failed(message) => Err(Error::agent(message)),
AgentResponse::Pong | AgentResponse::Authenticated(_) => {
Err(Error::agent("unexpected agent response to an Execute"))
}
}
})
}
fn base_url(&self) -> &str {
self.caps.get().map_or("", |caps| caps.base_url.as_str())
}
fn is_authenticated(&self) -> bool {
self.is_session_authenticated()
}
}
pub async fn serve(
executor: Arc<dyn RequestExecutor>,
socket_path: &Path,
access: AccessLevel,
token: AuthToken,
on_connect: impl FnOnce() + Send,
) -> Result<()> {
let listener = bind(socket_path).await?;
let gate = Arc::new(tokio::sync::Mutex::new(Gate {
token,
consumed: false,
}));
let authenticated = Arc::new(Notify::new());
let session_ended = Arc::new(Notify::new());
let accept = tokio::spawn(accept_loop(
listener,
gate,
executor,
access,
Arc::clone(&authenticated),
Arc::clone(&session_ended),
));
authenticated.notified().await;
on_connect();
session_ended.notified().await;
accept.abort();
Ok(())
}
struct Gate {
token: AuthToken,
consumed: bool,
}
async fn try_consume(gate: &tokio::sync::Mutex<Gate>, candidate: &str) -> bool {
let mut gate = gate.lock().await;
if gate.consumed {
return false;
}
if gate.token.verify(candidate) {
gate.consumed = true;
true
} else {
false
}
}
async fn accept_loop(
listener: UnixListener,
gate: Arc<tokio::sync::Mutex<Gate>>,
executor: Arc<dyn RequestExecutor>,
access: AccessLevel,
authenticated: Arc<Notify>,
session_ended: Arc<Notify>,
) {
let mut sessions = JoinSet::new();
loop {
while sessions.try_join_next().is_some() {}
let Ok((stream, _addr)) = listener.accept().await else {
return;
};
let gate = Arc::clone(&gate);
let executor = Arc::clone(&executor);
let authenticated = Arc::clone(&authenticated);
let session_ended = Arc::clone(&session_ended);
sessions.spawn(async move {
handle_connection(
stream,
&gate,
executor.as_ref(),
access,
&authenticated,
&session_ended,
)
.await;
});
}
}
async fn bind(socket_path: &Path) -> Result<UnixListener> {
if socket_path.exists() {
if UnixStream::connect(socket_path).await.is_ok() {
return Err(Error::agent(format!(
"an agent is already listening on {}",
socket_path.display()
)));
}
std::fs::remove_file(socket_path)
.map_err(|e| Error::agent(format!("could not remove stale socket: {e}")))?;
}
ensure_private_parent(socket_path)?;
let listener = UnixListener::bind(socket_path)
.map_err(|e| Error::agent(format!("could not bind {}: {e}", socket_path.display())))?;
set_socket_permissions(socket_path)?;
Ok(listener)
}
#[cfg(unix)]
fn ensure_private_parent(socket_path: &Path) -> Result<()> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let Some(parent) = socket_path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
if !parent.exists() {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)
.map_err(|e| Error::agent(format!("could not create {}: {e}", parent.display())))?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|e| Error::agent(format!("could not secure {}: {e}", parent.display())))?;
}
Ok(())
}
#[cfg(not(unix))]
fn ensure_private_parent(_socket_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_socket_permissions(socket_path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| Error::agent(format!("could not set socket permissions: {e}")))
}
#[cfg(not(unix))]
fn set_socket_permissions(_socket_path: &Path) -> Result<()> {
Ok(())
}
async fn handle_connection(
mut stream: UnixStream,
gate: &tokio::sync::Mutex<Gate>,
executor: &dyn RequestExecutor,
access: AccessLevel,
authenticated: &Notify,
session_ended: &Notify,
) {
let mut is_authenticated = false;
let mut attempts: u32 = 0;
loop {
let Ok(bytes) = read_frame_bytes(&mut stream, MAX_REQUEST_FRAME).await else {
break;
};
let request: AgentRequest = match decode(&bytes) {
Ok(request) => request,
Err(_) => break,
};
if !is_authenticated {
match request {
AgentRequest::Authenticate(candidate) => {
let candidate = Zeroizing::new(candidate);
if try_consume(gate, candidate.as_str()).await {
is_authenticated = true;
authenticated.notify_one();
let caps = Capabilities {
base_url: executor.base_url().to_owned(),
access,
};
if write_frame(&mut stream, &AgentResponse::Authenticated(caps))
.await
.is_err()
{
break;
}
} else {
attempts += 1;
let refused = AgentResponse::Failed(MSG_AUTH_FAILED.to_owned());
if write_frame(&mut stream, &refused).await.is_err()
|| attempts >= MAX_AUTH_ATTEMPTS
{
break;
}
}
}
AgentRequest::Ping | AgentRequest::Execute(_) => {
let refused = AgentResponse::Failed(MSG_AUTH_REQUIRED.to_owned());
if write_frame(&mut stream, &refused).await.is_err() {
break;
}
}
}
continue;
}
let response = match request {
AgentRequest::Ping => AgentResponse::Pong,
AgentRequest::Execute(wire) => execute_forwarded(executor, wire, access).await,
AgentRequest::Authenticate(_) => {
AgentResponse::Failed(MSG_ALREADY_AUTHENTICATED.to_owned())
}
};
if write_frame(&mut stream, &response).await.is_err() {
break;
}
}
if is_authenticated {
session_ended.notify_one();
}
}
async fn execute_forwarded(
executor: &dyn RequestExecutor,
wire: WireRequest,
access: AccessLevel,
) -> AgentResponse {
let Ok(method) = Method::from_bytes(wire.method.as_bytes()) else {
return AgentResponse::Failed(format!("invalid HTTP method '{}'", wire.method));
};
let required = required_access_for(method.as_str(), &wire.path);
if !access.permits(required) {
return AgentResponse::Failed(access_denied(required, access));
}
let spec =
RequestSpec::from_parts(method, wire.path, wire.query, wire.body, wire.requires_auth);
match executor.execute(spec).await {
Ok(raw) => AgentResponse::Executed(WireResponse {
status: raw.status,
retry_after_millis: raw
.retry_after
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
body: raw.body,
}),
Err(e) => AgentResponse::Failed(e.to_string()),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[derive(Debug)]
struct EchoExecutor;
impl RequestExecutor for EchoExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let body = request.path().as_bytes().to_vec();
Box::pin(async move {
Ok(RawResponse {
status: 200,
retry_after: None,
body,
})
})
}
#[allow(clippy::unnecessary_literal_bound)]
fn base_url(&self) -> &str {
"http://stub/api/1.0"
}
fn is_authenticated(&self) -> bool {
true
}
}
async fn wait_for(path: &Path) {
for _ in 0..200 {
if path.exists() {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("socket never appeared");
}
fn spawn_echo(
socket: PathBuf,
access: AccessLevel,
) -> (String, tokio::task::JoinHandle<Result<()>>) {
let token = AuthToken::generate().unwrap();
let secret = token.as_str().to_owned();
let handle = tokio::spawn(async move {
serve(Arc::new(EchoExecutor), &socket, access, token, || {}).await
});
(secret, handle)
}
#[test]
fn token_generate_is_unique_and_verifies_in_constant_time() {
let a = AuthToken::generate().unwrap();
let b = AuthToken::generate().unwrap();
assert_ne!(a.as_str(), b.as_str(), "tokens must be unique");
assert!(!a.as_str().is_empty());
assert!(a.verify(a.as_str()));
assert!(!a.verify(b.as_str()));
assert!(!a.verify(""));
assert!(!format!("{a:?}").contains(a.as_str()));
}
#[tokio::test]
async fn authenticated_round_trips_and_reports_capabilities() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::View);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
assert!(!executor.is_session_authenticated());
assert_eq!(executor.base_url(), "");
let err = executor
.execute(RequestSpec::get("/balances"))
.await
.unwrap_err();
assert!(matches!(err, Error::Agent { .. }));
executor.authenticate(&token).await.unwrap();
assert!(executor.is_session_authenticated());
assert_eq!(executor.base_url(), "http://stub/api/1.0");
assert_eq!(executor.access(), AccessLevel::View);
executor.ping().await.unwrap();
let raw = executor
.execute(RequestSpec::get("/balances"))
.await
.unwrap();
assert_eq!(raw.status, 200);
assert_eq!(raw.body, b"/balances");
let raw = executor
.execute(RequestSpec::get("/orders/active"))
.await
.unwrap();
assert_eq!(raw.body, b"/orders/active");
server.abort();
}
#[tokio::test]
async fn wrong_token_is_refused_then_correct_token_still_works() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::View);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
assert!(executor.authenticate("not-the-token").await.is_err());
assert!(!executor.is_session_authenticated());
executor.authenticate(&token).await.unwrap();
assert!(executor.is_session_authenticated());
server.abort();
}
#[tokio::test]
async fn token_is_single_use_across_connections() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::View);
wait_for(&socket).await;
let first = AgentExecutor::connect(&socket).await.unwrap();
first.authenticate(&token).await.unwrap();
first.ping().await.unwrap();
let second = AgentExecutor::connect(&socket).await.unwrap();
assert!(second.authenticate(&token).await.is_err());
assert!(!second.is_session_authenticated());
first.ping().await.unwrap();
server.abort();
}
#[tokio::test]
async fn view_access_allows_reads_refuses_mutations() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::View);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
executor.authenticate(&token).await.unwrap();
assert_eq!(executor.access(), AccessLevel::View);
executor
.execute(RequestSpec::get("/orders/active"))
.await
.unwrap();
let err = executor
.execute(RequestSpec::delete("/orders/abc"))
.await
.unwrap_err();
assert!(matches!(err, Error::Agent { .. }));
server.abort();
}
#[tokio::test]
async fn market_access_allows_only_public_data() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::Market);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
executor.authenticate(&token).await.unwrap();
assert_eq!(executor.access(), AccessLevel::Market);
let raw = executor
.execute(RequestSpec::get("/tickers"))
.await
.unwrap();
assert_eq!(raw.body, b"/tickers");
for path in ["/balances", "/orders/active", "/trades/private/BTC-USD"] {
let err = executor.execute(RequestSpec::get(path)).await.unwrap_err();
assert!(matches!(err, Error::Agent { .. }), "should refuse {path}");
}
let err = executor
.execute(RequestSpec::delete("/orders/abc"))
.await
.unwrap_err();
assert!(matches!(err, Error::Agent { .. }));
server.abort();
}
#[tokio::test]
async fn trading_access_allows_mutations() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::Trading);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
executor.authenticate(&token).await.unwrap();
assert_eq!(executor.access(), AccessLevel::Trading);
let raw = executor
.execute(RequestSpec::delete("/orders/abc"))
.await
.unwrap();
assert_eq!(raw.body, b"/orders/abc");
server.abort();
}
#[tokio::test]
async fn on_connect_fires_once_on_authentication_not_on_accept() {
use std::sync::atomic::{AtomicU64, Ordering};
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let token = AuthToken::generate().unwrap();
let secret = token.as_str().to_owned();
let connects = Arc::new(AtomicU64::new(0));
let on_connect = {
let connects = Arc::clone(&connects);
move || {
connects.fetch_add(1, Ordering::Relaxed);
}
};
let server = {
let socket = socket.clone();
tokio::spawn(async move {
serve(
Arc::new(EchoExecutor),
&socket,
AccessLevel::View,
token,
on_connect,
)
.await
})
};
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(connects.load(Ordering::Relaxed), 0, "not authenticated yet");
executor.authenticate(&secret).await.unwrap();
executor.ping().await.unwrap();
executor.ping().await.unwrap();
assert_eq!(connects.load(Ordering::Relaxed), 1);
server.abort();
}
#[tokio::test]
async fn serve_returns_when_the_authenticated_client_disconnects() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (token, server) = spawn_echo(socket.clone(), AccessLevel::View);
wait_for(&socket).await;
let executor = AgentExecutor::connect(&socket).await.unwrap();
executor.authenticate(&token).await.unwrap();
executor.ping().await.unwrap();
drop(executor);
let outcome = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("serve should return after the authenticated client leaves");
assert!(outcome.unwrap().is_ok());
}
}