feat: add local AIO agent over Unix socket

This commit is contained in:
Julien Denizot
2026-06-24 10:56:16 +02:00
parent 87e21c688b
commit 69aa1db9c6
5 changed files with 231 additions and 0 deletions
Generated
+9
View File
@@ -128,6 +128,15 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "enuxia-aio-agent"
version = "0.1.0"
dependencies = [
"enuxia-aio-protocol",
"serde_json",
"tokio",
]
[[package]]
name = "enuxia-aio-controller"
version = "0.1.0"
+1
View File
@@ -1,5 +1,6 @@
[workspace]
members = [
"crates/agent",
"crates/controller",
"crates/protocol",
]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "enuxia-aio-agent"
version = "0.1.0"
edition = "2024"
[dependencies]
enuxia-aio-protocol = {
path = "../protocol"
}
serde_json = "1"
tokio = {
version = "1",
features = [
"io-util",
"macros",
"net",
"rt-multi-thread",
"signal",
]
}
+186
View File
@@ -0,0 +1,186 @@
use std::{
env, fs, io,
os::unix::fs::{FileTypeExt, PermissionsExt},
path::{Path, PathBuf},
};
use enuxia_aio_protocol::{
AgentHealthResponse, AgentRequest, AgentResponse, AgentStatus, ApiError, PROTOCOL_VERSION,
};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::{UnixListener, UnixStream},
};
const MAX_REQUEST_SIZE: usize = 64 * 1024;
struct SocketCleanup {
path: PathBuf,
}
impl Drop for SocketCleanup {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn default_socket_path() -> PathBuf {
let runtime_directory = env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/run/user/1000"));
runtime_directory
.join("enuxia-frappe-aio")
.join("agent.sock")
}
fn prepare_socket_path(path: &Path) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"le socket doit avoir un répertoire parent",
)
})?;
fs::create_dir_all(parent)?;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
match fs::symlink_metadata(path) {
Ok(metadata) => {
if !metadata.file_type().is_socket() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} existe mais nest pas un socket Unix", path.display(),),
));
}
fs::remove_file(path)?;
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
Ok(())
}
fn error_response(code: impl Into<String>, message: impl Into<String>) -> AgentResponse {
AgentResponse::Error(ApiError {
code: code.into(),
message: message.into(),
})
}
fn process_request(request: AgentRequest) -> AgentResponse {
match request {
AgentRequest::Health { protocol_version } => {
if protocol_version != PROTOCOL_VERSION {
return error_response(
"unsupported_protocol_version",
format!(
"Version reçue : {protocol_version}, \
version attendue : {PROTOCOL_VERSION}.",
),
);
}
AgentResponse::Health(AgentHealthResponse {
protocol_version: PROTOCOL_VERSION,
agent_version: env!("CARGO_PKG_VERSION").to_owned(),
status: AgentStatus::Ready,
})
}
}
}
async fn handle_connection(stream: UnixStream) -> io::Result<()> {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut request_line = String::new();
let bytes_read = reader.read_line(&mut request_line).await?;
let response = if bytes_read == 0 {
error_response("empty_request", "La requête est vide.")
} else if request_line.len() > MAX_REQUEST_SIZE {
error_response(
"request_too_large",
"La requête dépasse la taille autorisée.",
)
} else {
match serde_json::from_str::<AgentRequest>(request_line.trim_end()) {
Ok(request) => process_request(request),
Err(error) => error_response(
"invalid_request",
format!("Requête JSON invalide : {error}"),
),
}
};
let mut payload = serde_json::to_vec(&response).map_err(io::Error::other)?;
payload.push(b'\n');
writer.write_all(&payload).await?;
writer.shutdown().await?;
Ok(())
}
#[tokio::main]
async fn main() -> io::Result<()> {
let socket_path = env::var_os("ENUXIA_AGENT_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(default_socket_path);
prepare_socket_path(&socket_path)?;
let listener = UnixListener::bind(&socket_path)?;
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
let _cleanup = SocketCleanup {
path: socket_path.clone(),
};
println!("Enuxia Frappe AIO Agent");
println!("Socket : {}", socket_path.display(),);
println!("Protocole : v{}", PROTOCOL_VERSION,);
loop {
tokio::select! {
result = listener.accept() => {
let (stream, _) = result?;
tokio::spawn(async move {
if let Err(error) =
handle_connection(stream).await
{
eprintln!(
"Erreur de connexion : {error}"
);
}
});
}
result = tokio::signal::ctrl_c() => {
result?;
println!(
"Arrêt demandé, fermeture de lagent."
);
break;
}
}
}
Ok(())
}
+13
View File
@@ -2,6 +2,19 @@ use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u16 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum AgentRequest {
Health { protocol_version: u16 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum AgentResponse {
Health(AgentHealthResponse),
Error(ApiError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {