netget/server/wol/mod.rs
1//! Wake-on-LAN magic packet listener.
2//!
3//! # Wake-on-LAN has no response, and that shapes everything here
4//!
5//! A magic packet is one-way. A real target NIC that recognises its own MAC in the payload
6//! pulls the machine's power up and says nothing at all — there is no acknowledgement frame,
7//! no status code, no transaction id, nothing the sender waits for. AMD's original
8//! "Magic Packet Technology" white paper defines a payload and a NIC behaviour; it defines no
9//! protocol exchange.
10//!
11//! So a Wake-on-LAN *server* is a **listener**, and the model has no reply to author. What it
12//! genuinely decides is: whether this packet is interesting (which MACs this host pretends to
13//! know about), and what is recorded about it. Those are the two actions
14//! (`record_wake_request`, `ignore_magic_packet`). A third, `announce_host_awake`, is
15//! explicitly **not part of Wake-on-LAN** and is off unless the operator turns it on — see
16//! `CLAUDE.md`.
17//!
18//! Because there is no reply, an LLM failure is naturally silent, and that silence is
19//! protocol-correct rather than a defect. It must still be distinguishable after the fact from
20//! a deliberate drop, so every datagram produces exactly one `decision=` line — see
21//! [`WolServer::decision_tag`].
22//!
23//! # The one decoding subtlety
24//!
25//! The magic packet is 6 bytes of `0xFF` followed by the 6-byte target MAC repeated exactly 16
26//! times (102 bytes), optionally followed by a 4- or 6-byte SecureON password. It may sit
27//! **anywhere inside the datagram** — senders wrap it, pad it, or encapsulate a whole Ethernet
28//! frame — so [`decode_magic_packet`] scans for the sync stream rather than assuming offset 0.
29
30pub mod actions;
31
32use crate::llm::action_helper::call_llm;
33use crate::llm::ollama_client::OllamaClient;
34use crate::protocol::Event;
35use crate::server::connection::ConnectionId;
36use crate::server::WolProtocol;
37use crate::state::app_state::AppState;
38use crate::{console_debug, console_error, console_info, console_trace, console_warn};
39use actions::WOL_MAGIC_PACKET_RECEIVED_EVENT;
40use anyhow::Result;
41use std::net::SocketAddr;
42use std::sync::Arc;
43use tokio::net::UdpSocket;
44use tokio::sync::mpsc;
45use tracing::{error, info, trace, warn};
46
47/// Bytes of `0xFF` that open a magic packet.
48pub const SYNC_STREAM_LEN: usize = 6;
49
50/// Length of an Ethernet MAC address.
51pub const MAC_LEN: usize = 6;
52
53/// How many times the target MAC is repeated. Exactly 16 — 15 is not a magic packet and
54/// 17 is 16 followed by a stray six bytes.
55pub const MAC_REPETITIONS: usize = 16;
56
57/// 6 + 6*16 = 102 bytes.
58pub const MAGIC_PACKET_LEN: usize = SYNC_STREAM_LEN + MAC_LEN * MAC_REPETITIONS;
59
60/// The two SecureON password lengths defined by the vendors that implement it.
61pub const SECURE_ON_PASSWORD_LENS: [usize; 2] = [4, 6];
62
63/// EtherType assigned to Wake-on-LAN, as it appears in an Ethernet header.
64const ETHERTYPE_WOL: [u8; 2] = [0x08, 0x42];
65
66/// Bytes of Ethernet header preceding the payload: 6 destination + 6 source + 2 EtherType.
67const ETHERNET_HEADER_LEN: usize = 14;
68
69/// Largest datagram we will read. A magic packet is 102 bytes; the ceiling exists only so a
70/// deliberately huge datagram cannot be used to make the scan expensive.
71const RECV_BUFFER_LEN: usize = 2048;
72
73/// How the magic packet reached us.
74///
75/// **Neither value means NetGet opened a raw socket** — it never does. See the
76/// `EncapsulatedEthernet` docs and `CLAUDE.md`.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Transport {
79 /// The magic packet was the UDP payload (the ordinary case: ports 9 and 7).
80 Udp,
81
82 /// The UDP payload was itself a complete Ethernet frame whose EtherType is `0x0842`,
83 /// with the magic packet as that frame's payload.
84 ///
85 /// This is what a relay or a capture-replay tool produces when it forwards the
86 /// link-layer form of Wake-on-LAN over UDP. It is recognised by the two bytes
87 /// immediately before the sync stream being `08 42` with a full 14-byte header in front
88 /// of them — a precise structural check, not a guess.
89 ///
90 /// It is **not** the same thing as receiving a real EtherType `0x0842` frame off the
91 /// wire: that needs a raw socket and NetGet's `wol` feature deliberately carries no
92 /// packet-capture dependency.
93 EncapsulatedEthernet,
94}
95
96impl Transport {
97 /// The token used in the event's `transport` field and in the log.
98 pub fn as_str(self) -> &'static str {
99 match self {
100 Transport::Udp => "udp",
101 Transport::EncapsulatedEthernet => "ethernet",
102 }
103 }
104}
105
106/// A magic packet located inside a received datagram.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct MagicPacket {
109 /// The target MAC, as the six raw bytes found on the wire.
110 pub target_mac: [u8; MAC_LEN],
111
112 /// Byte index of the first `0xFF` of the sync stream within the datagram.
113 pub sync_offset: usize,
114
115 /// 0, 4 or 6. See [`decode_magic_packet`] for when a trailer counts as a password.
116 pub password_len: usize,
117
118 /// How the packet was carried.
119 pub transport: Transport,
120}
121
122impl MagicPacket {
123 /// The target MAC formatted as `00:11:22:33:44:55`.
124 ///
125 /// The event never carries the raw bytes: models cannot reliably produce or parse them
126 /// (see the action & event design rules in the root `CLAUDE.md`).
127 pub fn mac_string(&self) -> String {
128 self.target_mac
129 .iter()
130 .map(|b| format!("{:02X}", b))
131 .collect::<Vec<_>>()
132 .join(":")
133 }
134
135 /// Whether a SecureON password followed the magic packet.
136 pub fn has_password(&self) -> bool {
137 self.password_len > 0
138 }
139}
140
141/// Find a magic packet anywhere inside `data`.
142///
143/// Returns the **first** offset at which a complete, fully-validated magic packet begins.
144/// Scanning matters: a datagram may carry the packet at a non-zero offset, and a naive
145/// "does it start with six 0xFF bytes" test both misses those and accepts near-misses.
146///
147/// # What is rejected
148///
149/// * fewer than 16 repetitions of the MAC (15 is the classic off-by-one, and is not a magic
150/// packet — the NIC's pattern matcher requires all sixteen);
151/// * a sync stream that is not exactly six `0xFF` bytes;
152/// * any repetition that differs from the first by a single byte.
153///
154/// A candidate that fails validation does not end the scan: the search continues from the
155/// next offset, because a datagram may contain a false sync stream before the real packet.
156///
157/// # SecureON password
158///
159/// SecureON is a vendor extension: 4 or 6 bytes appended after the 102. It is recognised
160/// only when the trailing bytes run to the **end of the datagram** and number exactly 4 or 6.
161/// Any other trailer length is reported as `password_len: 0` — with the packet anywhere in
162/// the datagram there is no way to tell a password from padding, and claiming a password we
163/// are not sure about would be worse than reporting none.
164///
165/// The password bytes themselves are deliberately not returned. Nothing here needs them, and
166/// they must not reach the model as raw bytes.
167pub fn decode_magic_packet(data: &[u8]) -> Option<MagicPacket> {
168 if data.len() < MAGIC_PACKET_LEN {
169 return None;
170 }
171
172 let last_possible_offset = data.len() - MAGIC_PACKET_LEN;
173 for sync_offset in 0..=last_possible_offset {
174 if data[sync_offset..sync_offset + SYNC_STREAM_LEN] != [0xFFu8; SYNC_STREAM_LEN] {
175 continue;
176 }
177
178 let mac_start = sync_offset + SYNC_STREAM_LEN;
179 let mut target_mac = [0u8; MAC_LEN];
180 target_mac.copy_from_slice(&data[mac_start..mac_start + MAC_LEN]);
181
182 // Repetition 0 is the MAC itself, so only 1..16 need comparing.
183 let all_repetitions_match = (1..MAC_REPETITIONS).all(|repetition| {
184 let start = mac_start + repetition * MAC_LEN;
185 data[start..start + MAC_LEN] == target_mac
186 });
187 if !all_repetitions_match {
188 continue;
189 }
190
191 let trailing = data.len() - (sync_offset + MAGIC_PACKET_LEN);
192 let password_len = if SECURE_ON_PASSWORD_LENS.contains(&trailing) {
193 trailing
194 } else {
195 0
196 };
197
198 // The EtherType sits in the two bytes immediately before the frame payload, and a
199 // full Ethernet header must precede it.
200 let transport = if sync_offset >= ETHERNET_HEADER_LEN
201 && data[sync_offset - 2..sync_offset] == ETHERTYPE_WOL
202 {
203 Transport::EncapsulatedEthernet
204 } else {
205 Transport::Udp
206 };
207
208 return Some(MagicPacket {
209 target_mac,
210 sync_offset,
211 password_len,
212 transport,
213 });
214 }
215
216 None
217}
218
219/// Wake-on-LAN magic packet listener.
220pub struct WolServer;
221
222impl WolServer {
223 /// Bind the UDP socket and start listening for magic packets.
224 ///
225 /// Returns `Err` if the socket cannot be bound or a startup parameter is unusable, so
226 /// `server_startup` sets `ServerStatus::Error` rather than showing a server that is
227 /// "Running" and listening to nothing.
228 pub async fn spawn_with_llm_actions(
229 listen_addr: SocketAddr,
230 llm_client: OllamaClient,
231 app_state: Arc<AppState>,
232 status_tx: mpsc::UnboundedSender<String>,
233 server_id: crate::state::ServerId,
234 startup_params: Option<crate::protocol::StartupParams>,
235 ) -> Result<SocketAddr> {
236 // Propagated with `?`: an undeclared key or a wrong-typed value must fail the start
237 // cleanly rather than panic the task (see the root CLAUDE.md on StartupParams).
238 let allow_non_standard_ack = match startup_params {
239 Some(ref params) => params
240 .get_optional_bool("allow_non_standard_ack")?
241 .unwrap_or(false),
242 None => false,
243 };
244
245 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
246 let local_addr = socket.local_addr()?;
247
248 info!(
249 "Wake-on-LAN receive-only listener: magic packets are decoded, nothing is ever \
250 sent back (non-standard announcements {})",
251 if allow_non_standard_ack {
252 "ENABLED"
253 } else {
254 "disabled"
255 }
256 );
257 // Keep the address last in this line: the e2e harness recognises a bound server by
258 // "listening on ADDR:PORT" and reads back from the end of it.
259 console_info!(status_tx, "Wake-on-LAN listening on {}", local_addr);
260 if allow_non_standard_ack {
261 console_warn!(
262 status_tx,
263 "Wake-on-LAN: allow_non_standard_ack=true - announce_host_awake will put \
264 datagrams on the network. This is NOT part of Wake-on-LAN"
265 );
266 }
267
268 let protocol = Arc::new(WolProtocol::new());
269
270 let task_registrar = app_state.clone();
271 let listen_handle = tokio::spawn(async move {
272 let mut buffer = vec![0u8; RECV_BUFFER_LEN];
273
274 loop {
275 let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
276 Ok(received) => received,
277 Err(e) => {
278 console_error!(status_tx, "Wake-on-LAN receive error: {}", e);
279 break;
280 }
281 };
282
283 // Guarded, because `console_trace!` renders its arguments and pushes a line
284 // onto the *unbounded* status channel whatever the level is set to. Port 9 is
285 // the discard port: this runs once per stray datagram from every scanner on
286 // the segment, and per-datagram work on an unbounded channel is what the root
287 // CLAUDE.md warns against.
288 if tracing::enabled!(tracing::Level::TRACE) {
289 console_trace!(
290 status_tx,
291 "Wake-on-LAN read {} bytes from {}: {}",
292 n,
293 peer_addr,
294 hex_summary(&buffer[..n])
295 );
296 }
297
298 // Port 9 is the discard port and attracts scanners and stray traffic. A
299 // datagram that is not a magic packet raises no event and costs no LLM call
300 // - it is not something the model can decide anything about.
301 let Some(packet) = decode_magic_packet(&buffer[..n]) else {
302 console_debug!(
303 status_tx,
304 "Wake-on-LAN discarded {} bytes from {}: not a magic packet (needs \
305 6x0xFF then the same MAC 16 times = {} bytes, at any offset)",
306 n,
307 peer_addr,
308 MAGIC_PACKET_LEN
309 );
310 continue;
311 };
312
313 let target_mac = packet.mac_string();
314 console_info!(
315 status_tx,
316 "Wake-on-LAN magic packet for {} from {} (offset {}, {}, password_length={})",
317 target_mac,
318 peer_addr,
319 packet.sync_offset,
320 packet.transport.as_str(),
321 packet.password_len
322 );
323
324 // A "connection" here is a recent sender, kept for the dashboard's counters
325 // only. The protocol declares `.connectionless()`, so the 10-second idle
326 // sweep is what removes these; nothing else ever closes them.
327 let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
328 {
329 use crate::state::server::{
330 ConnectionState as ServerConnectionState, ConnectionStatus,
331 ProtocolConnectionInfo,
332 };
333 let now = crate::utils::clock::Instant::now();
334 let conn_state = ServerConnectionState {
335 id: connection_id,
336 remote_addr: peer_addr,
337 local_addr,
338 bytes_sent: 0,
339 bytes_received: n as u64,
340 packets_sent: 0,
341 packets_received: 1,
342 last_activity: now,
343 status: ConnectionStatus::Active,
344 status_changed_at: now,
345 protocol_info: ProtocolConnectionInfo::empty(),
346 };
347 app_state
348 .add_connection_to_server(server_id, conn_state)
349 .await;
350 }
351 let _ = status_tx.send("__UPDATE_UI__".to_string());
352
353 let llm_clone = llm_client.clone();
354 let state_clone = app_state.clone();
355 let status_clone = status_tx.clone();
356 let protocol_clone = protocol.clone();
357
358 // Tracked, not detached: stop_server must abort this task too.
359 let task_owner = app_state.clone();
360 task_owner
361 .spawn_server_task(server_id, async move {
362 let event = Event::new(
363 &WOL_MAGIC_PACKET_RECEIVED_EVENT,
364 serde_json::json!({
365 "target_mac": target_mac,
366 "source_address": peer_addr.to_string(),
367 "has_password": packet.has_password(),
368 "password_length": packet.password_len,
369 "transport": packet.transport.as_str(),
370 "sync_offset": packet.sync_offset,
371 }),
372 );
373
374 match call_llm(
375 &llm_clone,
376 &state_clone,
377 server_id,
378 Some(connection_id),
379 &event,
380 protocol_clone.as_ref(),
381 )
382 .await
383 {
384 Ok(execution_result) => {
385 for message in &execution_result.messages {
386 info!("{}", message);
387 let _ = status_clone.send(format!("[INFO] {}", message));
388 }
389
390 let decision = Self::decision_tag(&execution_result);
391 info!(
392 "Wake-on-LAN magic packet for {} from {} decision={} \
393 ({} action(s), {} failed)",
394 target_mac,
395 peer_addr,
396 decision,
397 execution_result.raw_actions.len(),
398 execution_result.failures.len()
399 );
400 let _ = status_clone.send(format!(
401 "[INFO] Wake-on-LAN {} from {} decision={}",
402 target_mac, peer_addr, decision
403 ));
404
405 Self::process_announcements(
406 &execution_result.raw_actions,
407 allow_non_standard_ack,
408 peer_addr,
409 &target_mac,
410 &status_clone,
411 )
412 .await;
413 }
414 Err(e) => {
415 // Nothing goes on the wire, and that is protocol-correct rather
416 // than a compromise: Wake-on-LAN defines no reply of any kind, so
417 // there is no error form to send and no peer waiting for one.
418 // What must NOT happen is that this silence looks identical to a
419 // deliberate `ignore_magic_packet` afterwards - hence the
420 // distinct tag, and the category split so an overload is
421 // distinguishable from a hard failure.
422 let category = crate::utils::WireFailure::classify(&e);
423 let category_tag = if category.is_overloaded() {
424 "overloaded"
425 } else {
426 "unavailable"
427 };
428 error!(
429 "Wake-on-LAN magic packet for {} from {} \
430 decision=fail_closed_llm_error category={} \
431 (no reply possible: Wake-on-LAN defines no response): {}",
432 target_mac, peer_addr, category_tag, e
433 );
434 let _ = status_clone.send(format!(
435 "✗ Wake-on-LAN {} from {} decision=fail_closed_llm_error \
436 category={}: {}",
437 target_mac, peer_addr, category_tag, e
438 ));
439 }
440 }
441 })
442 .await;
443 }
444 });
445
446 task_registrar
447 .register_server_task(server_id, listen_handle)
448 .await;
449
450 Ok(local_addr)
451 }
452
453 /// Classify what the model decided about one magic packet, for the log.
454 ///
455 /// Wake-on-LAN cannot answer its sender, so "the model recognised the host", "the model
456 /// dropped it", "the model said nothing" and "the LLM call failed" are all indis-
457 /// tinguishable on the wire — all four are silence. They must not be indistinguishable in
458 /// the log as well: the first two are decisions, the last two are NetGet failing to make
459 /// one. The failure case is tagged at the call site as `decision=fail_closed_llm_error`;
460 /// this covers the three successful-call outcomes.
461 ///
462 /// The tokens are stable so an operator can grep `decision=model_silent` and
463 /// `decision=fail_closed_llm_error` for every packet NetGet did not really handle.
464 fn decision_tag(result: &crate::llm::ExecutionResult) -> &'static str {
465 let mut recognised = false;
466 let mut ignored = false;
467
468 for action in &result.raw_actions {
469 match action.get("type").and_then(|v| v.as_str()) {
470 Some(actions::RECORD_WAKE_REQUEST) | Some(actions::ANNOUNCE_HOST_AWAKE) => {
471 recognised = true
472 }
473 Some(actions::IGNORE_MAGIC_PACKET) => ignored = true,
474 // Generic actions (show_message, update_instruction, …) say nothing about
475 // this packet, so they do not count as a decision either way.
476 _ => {}
477 }
478 }
479
480 if recognised {
481 "model_accept"
482 } else if ignored {
483 "model_reject"
484 } else {
485 // The call succeeded but produced nothing that decides the packet - either no
486 // actions at all, or only generic ones. Dropping it is what happens anyway, but
487 // it was not a decision.
488 "model_silent"
489 }
490 }
491
492 /// Perform any `announce_host_awake` actions the model returned.
493 ///
494 /// This is the only path in the whole protocol that puts bytes on the network, and it is
495 /// **not part of Wake-on-LAN**. It runs here rather than in `execute_action` for two
496 /// reasons: the gate (`allow_non_standard_ack`) is a per-server startup parameter that
497 /// the stateless protocol struct cannot see, and the default destination is the magic
498 /// packet's own source address, which only this loop knows.
499 ///
500 /// `WolProtocol::execute_action` therefore validates the action and returns
501 /// `ActionResult::NoAction`; the send happens exactly once, here.
502 async fn process_announcements(
503 raw_actions: &[serde_json::Value],
504 allow_non_standard_ack: bool,
505 peer_addr: SocketAddr,
506 target_mac: &str,
507 status_tx: &mpsc::UnboundedSender<String>,
508 ) {
509 for action in raw_actions {
510 if action.get("type").and_then(|v| v.as_str()) != Some(actions::ANNOUNCE_HOST_AWAKE) {
511 continue;
512 }
513
514 if !allow_non_standard_ack {
515 warn!(
516 "Wake-on-LAN refused announce_host_awake for {}: it is not part of \
517 Wake-on-LAN and allow_non_standard_ack is false (the default). Nothing \
518 was sent",
519 target_mac
520 );
521 console_warn!(
522 status_tx,
523 "Wake-on-LAN refused announce_host_awake for {}: start the server with \
524 allow_non_standard_ack=true to enable it",
525 target_mac
526 );
527 continue;
528 }
529
530 match Self::send_announcement(action, peer_addr, target_mac).await {
531 Ok((destination, bytes)) => {
532 info!(
533 "Wake-on-LAN sent a NON-STANDARD awake announcement for {} to {} \
534 ({} bytes)",
535 target_mac, destination, bytes
536 );
537 console_info!(
538 status_tx,
539 "→ Wake-on-LAN announced {} awake to {} (non-standard)",
540 target_mac,
541 destination
542 );
543 }
544 Err(e) => {
545 error!(
546 "Wake-on-LAN announce_host_awake for {} failed: {}",
547 target_mac, e
548 );
549 console_error!(
550 status_tx,
551 "✗ Wake-on-LAN announce_host_awake for {} failed: {}",
552 target_mac,
553 e
554 );
555 }
556 }
557 }
558 }
559
560 /// Send one awake announcement, returning the destination and the byte count.
561 async fn send_announcement(
562 action: &serde_json::Value,
563 peer_addr: SocketAddr,
564 target_mac: &str,
565 ) -> Result<(SocketAddr, usize)> {
566 let destination = match action.get("announce_to").and_then(|v| v.as_str()) {
567 Some(target) => actions::resolve_announce_target(target)?,
568 // No destination given: answer the machine that sent the magic packet. This is
569 // the "simulated acknowledgement" shape, and it is still non-standard.
570 None => peer_addr,
571 };
572
573 let message = action
574 .get("message")
575 .and_then(|v| v.as_str())
576 .map(str::to_string)
577 .unwrap_or_else(|| format!("netget-wol: host {} is awake", target_mac));
578
579 let bind_addr = if destination.is_ipv4() {
580 "0.0.0.0:0"
581 } else {
582 "[::]:0"
583 };
584 let socket = UdpSocket::bind(bind_addr).await?;
585 let sent = socket.send_to(message.as_bytes(), destination).await?;
586
587 trace!("Wake-on-LAN announcement to {}: {:?}", destination, message);
588
589 Ok((destination, sent))
590 }
591}
592
593/// A short hex rendering of a datagram, for TRACE logging only.
594///
595/// Never reaches the model — the event carries structured fields, never bytes.
596fn hex_summary(data: &[u8]) -> String {
597 const MAX: usize = 64;
598 let shown = data.len().min(MAX);
599 let mut out = String::with_capacity(shown * 2 + 16);
600 for byte in &data[..shown] {
601 out.push_str(&format!("{:02x}", byte));
602 }
603 if data.len() > shown {
604 out.push_str(&format!("… (+{} bytes)", data.len() - shown));
605 }
606 out
607}