This commit is contained in:
lda
2026-04-11 16:04:14 +07:00 Unverified
parent a945d4933e
commit 524ade16ec
7 changed files with 611 additions and 405 deletions
+2
View File
@@ -1 +1,3 @@
We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, so you help me. We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, so you help me.
DRY: spawn subagents.
+185
View File
@@ -0,0 +1,185 @@
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
use crate::runtime::{AgentReply, AppState};
#[derive(Debug, Deserialize)]
pub struct EnrollRequest {
pub enroll_token: String,
}
#[derive(Debug, Serialize)]
pub struct EnrollResponse {
pub agent_id: String,
pub agent_token: String,
pub server_url: String,
}
#[derive(Debug, Serialize)]
pub struct AgentStatus {
pub agent_id: String,
pub connected: bool,
}
#[derive(Debug, Deserialize)]
pub struct RelayCommandRequest {
pub command: AgentCommand,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct RelayCommandResponse {
pub request_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorPayload>,
}
pub async fn healthz() -> &'static str {
"ok"
}
pub async fn enroll(
State(state): State<AppState>,
Json(req): Json<EnrollRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.enroll(&req.enroll_token).await {
Ok(issued) => Ok((
StatusCode::OK,
Json(EnrollResponse {
agent_id: issued.agent_id,
agent_token: issued.agent_token,
server_url: state.public_url,
}),
)),
Err(err) => Err(json_error(
StatusCode::UNAUTHORIZED,
"enrollment_rejected",
&err.to_string(),
)),
}
}
pub async fn list_agents(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let enrolled = state.store.list_agents().await;
let sessions = state.sessions.read().await;
let agents = enrolled
.into_iter()
.map(|agent_id| AgentStatus {
connected: sessions.contains_key(&agent_id),
agent_id,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(agents)))
}
pub async fn run_command(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
Json(req): Json<RelayCommandRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let request_id_string = format!("req-{}", Uuid::new_v4());
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"invalid_request_id",
&err,
)
})?;
let tx = {
let sessions = state.sessions.read().await;
sessions.get(&agent_id).cloned()
}
.ok_or_else(|| {
json_error(
StatusCode::NOT_FOUND,
"agent_not_connected",
"agent is not connected",
)
})?;
let (pending_tx, pending_rx) = tokio::sync::oneshot::channel();
state
.pending
.lock()
.await
.insert(request_id_string.clone(), pending_tx);
if let Err(err) = tx.send(ServerMessage::Command {
request_id,
command: req.command,
}) {
state.pending.lock().await.remove(&request_id_string);
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_send_failed",
&format!("failed to send command to agent: {err}"),
));
}
let timeout = std::time::Duration::from_millis(
req.timeout_ms
.unwrap_or(state.command_timeout.as_millis() as u64)
.max(1),
);
let outcome = tokio::time::timeout(timeout, pending_rx).await;
let response = match outcome {
Ok(Ok(AgentReply::Result(result))) => RelayCommandResponse {
request_id: request_id_string,
status: "ok".into(),
result: Some(result),
error: None,
},
Ok(Ok(AgentReply::Error(error))) => RelayCommandResponse {
request_id: request_id_string,
status: "error".into(),
result: None,
error: Some(error),
},
Ok(Err(_)) => {
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_response_dropped",
"agent response channel dropped",
));
}
Err(_) => {
state.pending.lock().await.remove(&request_id_string);
return Err(json_error(
StatusCode::GATEWAY_TIMEOUT,
"agent_timeout",
"agent did not answer before timeout",
));
}
};
Ok((StatusCode::OK, Json(response)))
}
pub fn json_error(
status: StatusCode,
code: &str,
message: &str,
) -> (StatusCode, Json<serde_json::Value>) {
(
status,
Json(serde_json::json!({
"error": {
"code": code,
"message": message,
}
})),
)
}
+67
View File
@@ -0,0 +1,67 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand};
pub const DEFAULT_STATE_FILE: &str = "/var/lib/wakey-control-plane/state.json";
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-control-plane.pid";
#[derive(Parser)]
#[command(name = "wakey-control-plane")]
#[command(version, about = "Control plane server for wakey-agent fleets")]
pub struct Cli {
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Run the control-plane daemon.
Serve(ServeArgs),
/// Create a new enroll token for provisioning a router.
IssueEnrollToken(IssueEnrollTokenArgs),
/// Send SIGHUP to an already-running daemon.
Reload(ReloadArgs),
}
#[derive(Args, Clone)]
pub struct ServeArgs {
#[arg(long, default_value = "0.0.0.0:8080")]
pub bind: SocketAddr,
#[arg(long, default_value = "http://127.0.0.1:8080")]
pub public_url: String,
#[arg(long, default_value = DEFAULT_STATE_FILE)]
pub state_file: PathBuf,
#[arg(long = "enroll-token")]
pub enroll_tokens: Vec<String>,
#[arg(long, default_value_t = 30_000)]
pub command_timeout_ms: u64,
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
#[derive(Args)]
pub struct IssueEnrollTokenArgs {
#[arg(long, default_value = DEFAULT_STATE_FILE)]
pub state_file: PathBuf,
#[arg(long = "enroll-token")]
pub enroll_tokens: Vec<String>,
#[arg(long)]
pub public_url: Option<String>,
}
#[derive(Args)]
pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
+11 -405
View File
@@ -1,416 +1,22 @@
mod api;
mod cli;
mod runtime;
mod state; mod state;
mod tracing; mod tracing;
mod ws;
use std::collections::HashMap; use anyhow::Result;
use std::net::SocketAddr; use clap::Parser;
use std::sync::Arc; use cli::{Cli, Command};
use std::time::Duration;
use ::tracing::{debug, info, warn};
use anyhow::{Context, Result};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use clap::{ArgAction, Parser};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
use uuid::Uuid;
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
#[derive(Parser)]
#[command(name = "wakey-control-plane")]
#[command(version, about = "Control plane server for wakey-agent fleets")]
struct Cli {
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
verbose: u8,
#[arg(long, default_value = "0.0.0.0:8080")]
bind: SocketAddr,
#[arg(long, default_value = "http://127.0.0.1:8080")]
public_url: String,
#[arg(long, default_value = "/var/lib/wakey-control-plane/state.json")]
state_file: std::path::PathBuf,
#[arg(long = "enroll-token")]
enroll_tokens: Vec<String>,
#[arg(long, default_value_t = 30_000)]
command_timeout_ms: u64,
}
#[derive(Clone)]
struct AppState {
store: Arc<state::Store>,
sessions: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<ServerMessage>>>>,
pending: Arc<Mutex<HashMap<String, oneshot::Sender<AgentReply>>>>,
public_url: String,
command_timeout: Duration,
}
enum AgentReply {
Result(serde_json::Value),
Error(ErrorPayload),
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum IncomingClientMessage {
Hello {
agent_id: String,
},
Auth {
agent_id: String,
agent_token: String,
},
Heartbeat {
agent_id: String,
},
Result {
request_id: RequestId,
result: serde_json::Value,
},
Error {
request_id: RequestId,
error: ErrorPayload,
},
}
#[derive(Debug, Deserialize)]
struct EnrollRequest {
enroll_token: String,
}
#[derive(Debug, Serialize)]
struct EnrollResponse {
agent_id: String,
agent_token: String,
server_url: String,
}
#[derive(Debug, Serialize)]
struct AgentStatus {
agent_id: String,
connected: bool,
}
#[derive(Debug, Deserialize)]
struct RelayCommandRequest {
command: AgentCommand,
timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
struct RelayCommandResponse {
request_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ErrorPayload>,
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
tracing::init(cli.verbose); tracing::init(cli.verbose);
let store = state::Store::load_or_init(&cli.state_file, cli.enroll_tokens) match cli.command {
.await Command::Serve(args) => runtime::serve(args).await,
.with_context(|| format!("failed to initialize store {}", cli.state_file.display()))?; Command::IssueEnrollToken(args) => runtime::issue_enroll_token(args).await,
Command::Reload(args) => runtime::reload_daemon(&args.pid_file),
let app_state = AppState {
store: Arc::new(store),
sessions: Arc::new(RwLock::new(HashMap::new())),
pending: Arc::new(Mutex::new(HashMap::new())),
public_url: cli.public_url.trim_end_matches('/').to_string(),
command_timeout: Duration::from_millis(cli.command_timeout_ms.max(1)),
};
let app = Router::new()
.route("/healthz", get(healthz))
.route("/api/v1/agents/enroll", post(enroll))
.route("/api/v1/agent/ws", get(agent_ws))
.route("/api/v1/control/agents", get(list_agents))
.route(
"/api/v1/control/agents/{agent_id}/command",
post(run_command),
)
.with_state(app_state);
info!(bind = %cli.bind, "starting control-plane server");
let listener = TcpListener::bind(cli.bind).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn healthz() -> &'static str {
"ok"
}
async fn enroll(
State(state): State<AppState>,
Json(req): Json<EnrollRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.enroll(&req.enroll_token).await {
Ok(issued) => Ok((
StatusCode::OK,
Json(EnrollResponse {
agent_id: issued.agent_id,
agent_token: issued.agent_token,
server_url: state.public_url,
}),
)),
Err(err) => Err(json_error(
StatusCode::UNAUTHORIZED,
"enrollment_rejected",
&err.to_string(),
)),
} }
} }
async fn list_agents(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let enrolled = state.store.list_agents().await;
let sessions = state.sessions.read().await;
let agents = enrolled
.into_iter()
.map(|agent_id| AgentStatus {
connected: sessions.contains_key(&agent_id),
agent_id,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(agents)))
}
async fn run_command(
State(state): State<AppState>,
Path(agent_id): Path<String>,
Json(req): Json<RelayCommandRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let request_id_string = format!("req-{}", Uuid::new_v4());
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"invalid_request_id",
&err,
)
})?;
let tx = {
let sessions = state.sessions.read().await;
sessions.get(&agent_id).cloned()
}
.ok_or_else(|| {
json_error(
StatusCode::NOT_FOUND,
"agent_not_connected",
"agent is not connected",
)
})?;
let (pending_tx, pending_rx) = oneshot::channel();
state
.pending
.lock()
.await
.insert(request_id_string.clone(), pending_tx);
if let Err(err) = tx.send(ServerMessage::Command {
request_id,
command: req.command,
}) {
state.pending.lock().await.remove(&request_id_string);
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_send_failed",
&format!("failed to send command to agent: {err}"),
));
}
let timeout = Duration::from_millis(
req.timeout_ms
.unwrap_or(state.command_timeout.as_millis() as u64)
.max(1),
);
let outcome = tokio::time::timeout(timeout, pending_rx).await;
let response = match outcome {
Ok(Ok(AgentReply::Result(result))) => RelayCommandResponse {
request_id: request_id_string,
status: "ok".into(),
result: Some(result),
error: None,
},
Ok(Ok(AgentReply::Error(error))) => RelayCommandResponse {
request_id: request_id_string,
status: "error".into(),
result: None,
error: Some(error),
},
Ok(Err(_)) => {
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_response_dropped",
"agent response channel dropped",
));
}
Err(_) => {
state.pending.lock().await.remove(&request_id_string);
return Err(json_error(
StatusCode::GATEWAY_TIMEOUT,
"agent_timeout",
"agent did not answer before timeout",
));
}
};
Ok((StatusCode::OK, Json(response)))
}
async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_agent_socket(state, socket))
}
async fn handle_agent_socket(state: AppState, socket: WebSocket) {
let (mut write, mut read) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
let encoded = match serde_json::to_string(&msg) {
Ok(s) => s,
Err(err) => {
warn!(error = %err, "failed to encode server websocket message");
continue;
}
};
if let Err(err) = write.send(Message::Text(encoded.into())).await {
warn!(error = %err, "failed to send websocket message");
break;
}
}
});
let mut authed_agent_id: Option<String> = None;
loop {
let frame = read.next().await;
let msg = match frame {
Some(Ok(msg)) => msg,
Some(Err(err)) => {
warn!(error = %err, "agent websocket receive error");
break;
}
None => break,
};
match msg {
Message::Text(text) => {
if let Err(err) = process_agent_text(&state, &tx, &mut authed_agent_id, &text).await
{
warn!(error = %err, "closing agent websocket due to protocol/auth error");
break;
}
}
Message::Ping(_) => {}
Message::Pong(_) => {}
Message::Close(_) => break,
Message::Binary(_) => {
debug!("ignoring unexpected binary websocket frame");
}
}
}
if let Some(agent_id) = authed_agent_id {
info!(agent_id = %agent_id, "agent disconnected");
state.sessions.write().await.remove(&agent_id);
}
writer.abort();
}
async fn process_agent_text(
state: &AppState,
tx: &mpsc::UnboundedSender<ServerMessage>,
authed_agent_id: &mut Option<String>,
text: &str,
) -> Result<()> {
let message: IncomingClientMessage =
serde_json::from_str(text).context("invalid client websocket payload")?;
match message {
IncomingClientMessage::Hello { agent_id } => {
debug!(agent_id = %agent_id, "agent hello received");
}
IncomingClientMessage::Auth {
agent_id,
agent_token,
} => {
if !state
.store
.verify_agent_token(&agent_id, &agent_token)
.await
{
anyhow::bail!("agent auth rejected");
}
state
.sessions
.write()
.await
.insert(agent_id.clone(), tx.clone());
*authed_agent_id = Some(agent_id.clone());
info!(agent_id = %agent_id, "agent authenticated");
}
IncomingClientMessage::Heartbeat { agent_id } => {
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
anyhow::bail!("heartbeat for unauthenticated or mismatched agent");
}
debug!(agent_id = %agent_id, "heartbeat received");
}
IncomingClientMessage::Result { request_id, result } => {
if authed_agent_id.is_none() {
anyhow::bail!("result before auth");
}
let key = request_id.as_str().to_string();
if let Some(waiter) = state.pending.lock().await.remove(&key) {
let _ = waiter.send(AgentReply::Result(result));
}
}
IncomingClientMessage::Error { request_id, error } => {
if authed_agent_id.is_none() {
anyhow::bail!("error before auth");
}
let key = request_id.as_str().to_string();
if let Some(waiter) = state.pending.lock().await.remove(&key) {
let _ = waiter.send(AgentReply::Error(error));
}
}
}
Ok(())
}
fn json_error(
status: StatusCode,
code: &str,
message: &str,
) -> (StatusCode, Json<serde_json::Value>) {
(
status,
Json(serde_json::json!({
"error": {
"code": code,
"message": message,
}
})),
)
}
+165
View File
@@ -0,0 +1,165 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::Router;
use axum::routing::{get, post};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
use tracing::{info, warn};
use wakey_agent::protocol::{ErrorPayload, ServerMessage};
use crate::api;
use crate::cli::{IssueEnrollTokenArgs, ServeArgs};
use crate::state;
use crate::ws;
#[derive(Clone)]
pub struct AppState {
pub store: Arc<state::Store>,
pub sessions: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<ServerMessage>>>>,
pub pending: Arc<Mutex<HashMap<String, oneshot::Sender<AgentReply>>>>,
pub public_url: String,
pub command_timeout: Duration,
}
pub enum AgentReply {
Result(serde_json::Value),
Error(ErrorPayload),
}
pub async fn serve(args: ServeArgs) -> Result<()> {
write_pid_file(&args.pid_file)?;
let store = state::Store::load_or_init(&args.state_file, args.enroll_tokens)
.await
.with_context(|| format!("failed to initialize store {}", args.state_file.display()))?;
let app_state = AppState {
store: Arc::new(store),
sessions: Arc::new(RwLock::new(HashMap::new())),
pending: Arc::new(Mutex::new(HashMap::new())),
public_url: args.public_url.trim_end_matches('/').to_string(),
command_timeout: Duration::from_millis(args.command_timeout_ms.max(1)),
};
let app = Router::new()
.route("/healthz", get(api::healthz))
.route("/api/v1/agents/enroll", post(api::enroll))
.route("/api/v1/agent/ws", get(ws::agent_ws))
.route("/api/v1/control/agents", get(api::list_agents))
.route(
"/api/v1/control/agents/{agent_id}/command",
post(api::run_command),
)
.with_state(app_state.clone());
info!(bind = %args.bind, pid_file = %args.pid_file.display(), "starting control-plane server");
let listener = TcpListener::bind(args.bind).await?;
let mut server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.context("control-plane server exited unexpectedly")
});
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
info!("ctrl-c received; shutting down control-plane");
server.abort();
break;
}
_ = hup.recv() => {
match app_state.store.reload_from_disk().await {
Ok(()) => info!("reloaded state from disk"),
Err(err) => warn!(error = %err, "failed to reload state from disk"),
}
}
join = &mut server => {
let _ = remove_pid_file(&args.pid_file);
return join.context("control-plane join failed")?;
}
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.context("failed waiting for ctrl-c")?;
server.abort();
}
let _ = remove_pid_file(&args.pid_file);
Ok(())
}
pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
let store = state::Store::load_or_init(&args.state_file, args.enroll_tokens)
.await
.with_context(|| format!("failed to initialize store {}", args.state_file.display()))?;
let token = store.issue_enroll_token().await?;
println!("enroll_token={token}");
if let Some(url) = args.public_url {
let base = url.trim_end_matches('/');
println!("agent_command=wakey-agent enroll --server-url {base} --enroll-token {token}");
}
Ok(())
}
pub fn reload_daemon(pid_file: &Path) -> Result<()> {
let pid = read_pid(pid_file)?;
send_hup(pid)
}
fn write_pid_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create pid dir {}", parent.display()))?;
}
std::fs::write(path, format!("{}\n", std::process::id()))
.with_context(|| format!("failed to write pid file {}", path.display()))
}
fn remove_pid_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).with_context(|| format!("failed to remove pid file {}", path.display()))
}
}
}
fn read_pid(path: &Path) -> Result<i32> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read pid file {}", path.display()))?;
let pid = raw
.trim()
.parse::<i32>()
.with_context(|| format!("invalid pid in {}", path.display()))?;
if pid <= 0 {
anyhow::bail!("invalid non-positive pid {pid}");
}
Ok(pid)
}
fn send_hup(pid: i32) -> Result<()> {
let status = std::process::Command::new("kill")
.arg("-HUP")
.arg(pid.to_string())
.status()
.context("failed to invoke kill -HUP")?;
if !status.success() {
anyhow::bail!("kill -HUP failed for pid {pid}");
}
Ok(())
}
+24
View File
@@ -70,6 +70,30 @@ impl Store {
}) })
} }
pub async fn issue_enroll_token(&self) -> Result<String> {
let token = format!("enr-{}", Uuid::new_v4());
let mut state = self.state.write().await;
state.enroll_tokens.insert(token.clone());
drop(state);
self.save().await?;
Ok(token)
}
pub async fn reload_from_disk(&self) -> Result<()> {
if !self.path.exists() {
return Ok(());
}
let raw = tokio::fs::read_to_string(&self.path)
.await
.with_context(|| format!("failed to read store {}", self.path.display()))?;
let decoded = serde_json::from_str::<PersistedState>(&raw)
.with_context(|| format!("failed to decode store {}", self.path.display()))?;
*self.state.write().await = decoded;
Ok(())
}
pub async fn verify_agent_token(&self, agent_id: &str, token: &str) -> bool { pub async fn verify_agent_token(&self, agent_id: &str, token: &str) -> bool {
self.state self.state
.read() .read()
+157
View File
@@ -0,0 +1,157 @@
use anyhow::{Context, Result};
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use wakey_agent::protocol::{ErrorPayload, RequestId, ServerMessage};
use crate::runtime::{AgentReply, AppState};
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum IncomingClientMessage {
Hello {
agent_id: String,
},
Auth {
agent_id: String,
agent_token: String,
},
Heartbeat {
agent_id: String,
},
Result {
request_id: RequestId,
result: serde_json::Value,
},
Error {
request_id: RequestId,
error: ErrorPayload,
},
}
pub async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_agent_socket(state, socket))
}
async fn handle_agent_socket(state: AppState, socket: WebSocket) {
let (mut write, mut read) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
let encoded = match serde_json::to_string(&msg) {
Ok(s) => s,
Err(err) => {
warn!(error = %err, "failed to encode server websocket message");
continue;
}
};
if let Err(err) = write.send(Message::Text(encoded.into())).await {
warn!(error = %err, "failed to send websocket message");
break;
}
}
});
let mut authed_agent_id: Option<String> = None;
loop {
let frame = read.next().await;
let msg = match frame {
Some(Ok(msg)) => msg,
Some(Err(err)) => {
warn!(error = %err, "agent websocket receive error");
break;
}
None => break,
};
match msg {
Message::Text(text) => {
if let Err(err) = process_agent_text(&state, &tx, &mut authed_agent_id, &text).await
{
warn!(error = %err, "closing agent websocket due to protocol/auth error");
break;
}
}
Message::Ping(_) => {}
Message::Pong(_) => {}
Message::Close(_) => break,
Message::Binary(_) => {
debug!("ignoring unexpected binary websocket frame");
}
}
}
if let Some(agent_id) = authed_agent_id {
info!(agent_id = %agent_id, "agent disconnected");
state.sessions.write().await.remove(&agent_id);
}
writer.abort();
}
async fn process_agent_text(
state: &AppState,
tx: &mpsc::UnboundedSender<ServerMessage>,
authed_agent_id: &mut Option<String>,
text: &str,
) -> Result<()> {
let message: IncomingClientMessage =
serde_json::from_str(text).context("invalid client websocket payload")?;
match message {
IncomingClientMessage::Hello { agent_id } => {
debug!(agent_id = %agent_id, "agent hello received");
}
IncomingClientMessage::Auth {
agent_id,
agent_token,
} => {
if !state
.store
.verify_agent_token(&agent_id, &agent_token)
.await
{
anyhow::bail!("agent auth rejected");
}
state
.sessions
.write()
.await
.insert(agent_id.clone(), tx.clone());
*authed_agent_id = Some(agent_id.clone());
info!(agent_id = %agent_id, "agent authenticated");
}
IncomingClientMessage::Heartbeat { agent_id } => {
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
anyhow::bail!("heartbeat for unauthenticated or mismatched agent");
}
debug!(agent_id = %agent_id, "heartbeat received");
}
IncomingClientMessage::Result { request_id, result } => {
if authed_agent_id.is_none() {
anyhow::bail!("result before auth");
}
let key = request_id.as_str().to_string();
if let Some(waiter) = state.pending.lock().await.remove(&key) {
let _ = waiter.send(AgentReply::Result(result));
}
}
IncomingClientMessage::Error { request_id, error } => {
if authed_agent_id.is_none() {
anyhow::bail!("error before auth");
}
let key = request_id.as_str().to_string();
if let Some(waiter) = state.pending.lock().await.remove(&key) {
let _ = waiter.send(AgentReply::Error(error));
}
}
}
Ok(())
}