pub mod actions;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::server::WolProtocol;
use crate::state::app_state::AppState;
use crate::{console_debug, console_error, console_info, console_trace, console_warn};
use actions::WOL_MAGIC_PACKET_RECEIVED_EVENT;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tracing::{error, info, trace, warn};
pub const SYNC_STREAM_LEN: usize = 6;
pub const MAC_LEN: usize = 6;
pub const MAC_REPETITIONS: usize = 16;
pub const MAGIC_PACKET_LEN: usize = SYNC_STREAM_LEN + MAC_LEN * MAC_REPETITIONS;
pub const SECURE_ON_PASSWORD_LENS: [usize; 2] = [4, 6];
const ETHERTYPE_WOL: [u8; 2] = [0x08, 0x42];
const ETHERNET_HEADER_LEN: usize = 14;
const RECV_BUFFER_LEN: usize = 2048;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Udp,
EncapsulatedEthernet,
}
impl Transport {
pub fn as_str(self) -> &'static str {
match self {
Transport::Udp => "udp",
Transport::EncapsulatedEthernet => "ethernet",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MagicPacket {
pub target_mac: [u8; MAC_LEN],
pub sync_offset: usize,
pub password_len: usize,
pub transport: Transport,
}
impl MagicPacket {
pub fn mac_string(&self) -> String {
self.target_mac
.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(":")
}
pub fn has_password(&self) -> bool {
self.password_len > 0
}
}
pub fn decode_magic_packet(data: &[u8]) -> Option<MagicPacket> {
if data.len() < MAGIC_PACKET_LEN {
return None;
}
let last_possible_offset = data.len() - MAGIC_PACKET_LEN;
for sync_offset in 0..=last_possible_offset {
if data[sync_offset..sync_offset + SYNC_STREAM_LEN] != [0xFFu8; SYNC_STREAM_LEN] {
continue;
}
let mac_start = sync_offset + SYNC_STREAM_LEN;
let mut target_mac = [0u8; MAC_LEN];
target_mac.copy_from_slice(&data[mac_start..mac_start + MAC_LEN]);
let all_repetitions_match = (1..MAC_REPETITIONS).all(|repetition| {
let start = mac_start + repetition * MAC_LEN;
data[start..start + MAC_LEN] == target_mac
});
if !all_repetitions_match {
continue;
}
let trailing = data.len() - (sync_offset + MAGIC_PACKET_LEN);
let password_len = if SECURE_ON_PASSWORD_LENS.contains(&trailing) {
trailing
} else {
0
};
let transport = if sync_offset >= ETHERNET_HEADER_LEN
&& data[sync_offset - 2..sync_offset] == ETHERTYPE_WOL
{
Transport::EncapsulatedEthernet
} else {
Transport::Udp
};
return Some(MagicPacket {
target_mac,
sync_offset,
password_len,
transport,
});
}
None
}
pub struct WolServer;
impl WolServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
startup_params: Option<crate::protocol::StartupParams>,
) -> Result<SocketAddr> {
let allow_non_standard_ack = match startup_params {
Some(ref params) => params
.get_optional_bool("allow_non_standard_ack")?
.unwrap_or(false),
None => false,
};
let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
let local_addr = socket.local_addr()?;
info!(
"Wake-on-LAN receive-only listener: magic packets are decoded, nothing is ever \
sent back (non-standard announcements {})",
if allow_non_standard_ack {
"ENABLED"
} else {
"disabled"
}
);
console_info!(status_tx, "Wake-on-LAN listening on {}", local_addr);
if allow_non_standard_ack {
console_warn!(
status_tx,
"Wake-on-LAN: allow_non_standard_ack=true - announce_host_awake will put \
datagrams on the network. This is NOT part of Wake-on-LAN"
);
}
let protocol = Arc::new(WolProtocol::new());
let task_registrar = app_state.clone();
let listen_handle = tokio::spawn(async move {
let mut buffer = vec![0u8; RECV_BUFFER_LEN];
loop {
let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
Ok(received) => received,
Err(e) => {
console_error!(status_tx, "Wake-on-LAN receive error: {}", e);
break;
}
};
if tracing::enabled!(tracing::Level::TRACE) {
console_trace!(
status_tx,
"Wake-on-LAN read {} bytes from {}: {}",
n,
peer_addr,
hex_summary(&buffer[..n])
);
}
let Some(packet) = decode_magic_packet(&buffer[..n]) else {
console_debug!(
status_tx,
"Wake-on-LAN discarded {} bytes from {}: not a magic packet (needs \
6x0xFF then the same MAC 16 times = {} bytes, at any offset)",
n,
peer_addr,
MAGIC_PACKET_LEN
);
continue;
};
let target_mac = packet.mac_string();
console_info!(
status_tx,
"Wake-on-LAN magic packet for {} from {} (offset {}, {}, password_length={})",
target_mac,
peer_addr,
packet.sync_offset,
packet.transport.as_str(),
packet.password_len
);
let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
{
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr: peer_addr,
local_addr,
bytes_sent: 0,
bytes_received: n as u64,
packets_sent: 0,
packets_received: 1,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let event = Event::new(
&WOL_MAGIC_PACKET_RECEIVED_EVENT,
serde_json::json!({
"target_mac": target_mac,
"source_address": peer_addr.to_string(),
"has_password": packet.has_password(),
"password_length": packet.password_len,
"transport": packet.transport.as_str(),
"sync_offset": packet.sync_offset,
}),
);
match call_llm(
&llm_clone,
&state_clone,
server_id,
Some(connection_id),
&event,
protocol_clone.as_ref(),
)
.await
{
Ok(execution_result) => {
for message in &execution_result.messages {
info!("{}", message);
let _ = status_clone.send(format!("[INFO] {}", message));
}
let decision = Self::decision_tag(&execution_result);
info!(
"Wake-on-LAN magic packet for {} from {} decision={} \
({} action(s), {} failed)",
target_mac,
peer_addr,
decision,
execution_result.raw_actions.len(),
execution_result.failures.len()
);
let _ = status_clone.send(format!(
"[INFO] Wake-on-LAN {} from {} decision={}",
target_mac, peer_addr, decision
));
Self::process_announcements(
&execution_result.raw_actions,
allow_non_standard_ack,
peer_addr,
&target_mac,
&status_clone,
)
.await;
}
Err(e) => {
let category = crate::utils::WireFailure::classify(&e);
let category_tag = if category.is_overloaded() {
"overloaded"
} else {
"unavailable"
};
error!(
"Wake-on-LAN magic packet for {} from {} \
decision=fail_closed_llm_error category={} \
(no reply possible: Wake-on-LAN defines no response): {}",
target_mac, peer_addr, category_tag, e
);
let _ = status_clone.send(format!(
"✗ Wake-on-LAN {} from {} decision=fail_closed_llm_error \
category={}: {}",
target_mac, peer_addr, category_tag, e
));
}
}
})
.await;
}
});
task_registrar
.register_server_task(server_id, listen_handle)
.await;
Ok(local_addr)
}
fn decision_tag(result: &crate::llm::ExecutionResult) -> &'static str {
let mut recognised = false;
let mut ignored = false;
for action in &result.raw_actions {
match action.get("type").and_then(|v| v.as_str()) {
Some(actions::RECORD_WAKE_REQUEST) | Some(actions::ANNOUNCE_HOST_AWAKE) => {
recognised = true
}
Some(actions::IGNORE_MAGIC_PACKET) => ignored = true,
_ => {}
}
}
if recognised {
"model_accept"
} else if ignored {
"model_reject"
} else {
"model_silent"
}
}
async fn process_announcements(
raw_actions: &[serde_json::Value],
allow_non_standard_ack: bool,
peer_addr: SocketAddr,
target_mac: &str,
status_tx: &mpsc::UnboundedSender<String>,
) {
for action in raw_actions {
if action.get("type").and_then(|v| v.as_str()) != Some(actions::ANNOUNCE_HOST_AWAKE) {
continue;
}
if !allow_non_standard_ack {
warn!(
"Wake-on-LAN refused announce_host_awake for {}: it is not part of \
Wake-on-LAN and allow_non_standard_ack is false (the default). Nothing \
was sent",
target_mac
);
console_warn!(
status_tx,
"Wake-on-LAN refused announce_host_awake for {}: start the server with \
allow_non_standard_ack=true to enable it",
target_mac
);
continue;
}
match Self::send_announcement(action, peer_addr, target_mac).await {
Ok((destination, bytes)) => {
info!(
"Wake-on-LAN sent a NON-STANDARD awake announcement for {} to {} \
({} bytes)",
target_mac, destination, bytes
);
console_info!(
status_tx,
"→ Wake-on-LAN announced {} awake to {} (non-standard)",
target_mac,
destination
);
}
Err(e) => {
error!(
"Wake-on-LAN announce_host_awake for {} failed: {}",
target_mac, e
);
console_error!(
status_tx,
"✗ Wake-on-LAN announce_host_awake for {} failed: {}",
target_mac,
e
);
}
}
}
}
async fn send_announcement(
action: &serde_json::Value,
peer_addr: SocketAddr,
target_mac: &str,
) -> Result<(SocketAddr, usize)> {
let destination = match action.get("announce_to").and_then(|v| v.as_str()) {
Some(target) => actions::resolve_announce_target(target)?,
None => peer_addr,
};
let message = action
.get("message")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("netget-wol: host {} is awake", target_mac));
let bind_addr = if destination.is_ipv4() {
"0.0.0.0:0"
} else {
"[::]:0"
};
let socket = UdpSocket::bind(bind_addr).await?;
let sent = socket.send_to(message.as_bytes(), destination).await?;
trace!("Wake-on-LAN announcement to {}: {:?}", destination, message);
Ok((destination, sent))
}
}
fn hex_summary(data: &[u8]) -> String {
const MAX: usize = 64;
let shown = data.len().min(MAX);
let mut out = String::with_capacity(shown * 2 + 16);
for byte in &data[..shown] {
out.push_str(&format!("{:02x}", byte));
}
if data.len() > shown {
out.push_str(&format!("… (+{} bytes)", data.len() - shown));
}
out
}