Skip to main content

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}