Skip to main content

netget/server/wol/
actions.rs

1//! Wake-on-LAN protocol actions.
2//!
3//! # The action set is small on purpose
4//!
5//! Wake-on-LAN defines no response (see `mod.rs`), so there is no reply for the model to
6//! author and no wire verb to offer it. Padding the list with invented verbs would hand the
7//! model a vocabulary that does not correspond to anything on the network.
8//!
9//! What is left is real, though, and it is what the event actually needs answering with:
10//!
11//! * `record_wake_request` — this packet is for a host I know about; keep it, with a label.
12//! * `ignore_magic_packet` — it is not; drop it, and say why.
13//! * `announce_host_awake` — **not part of Wake-on-LAN**, off unless the operator turns it on.
14//!
15//! Every one of them is attached to `wol_magic_packet_received` with `.with_actions(...)`, so
16//! the model is never asked a question it has no vocabulary to answer.
17//!
18//! There are **no async actions**. An async action is a user-triggered verb that needs no
19//! network context, and a listener that never transmits has none: `announce_host_awake` needs
20//! both the per-server gate and the magic packet's source address, neither of which exists
21//! outside the receive loop.
22
23use crate::llm::actions::{
24    protocol_trait::{ActionResult, Protocol, Server},
25    ActionDefinition, Parameter, ParameterDefinition,
26};
27use crate::protocol::log_template::LogTemplate;
28use crate::protocol::EventType;
29use crate::state::app_state::AppState;
30use anyhow::{Context, Result};
31use serde_json::json;
32use std::sync::LazyLock;
33
34/// Action name: keep this packet.
35pub const RECORD_WAKE_REQUEST: &str = "record_wake_request";
36
37/// Action name: drop this packet.
38pub const IGNORE_MAGIC_PACKET: &str = "ignore_magic_packet";
39
40/// Action name: the non-standard announcement. Off by default.
41pub const ANNOUNCE_HOST_AWAKE: &str = "announce_host_awake";
42
43/// Startup parameter gating [`ANNOUNCE_HOST_AWAKE`].
44pub const ALLOW_NON_STANDARD_ACK: &str = "allow_non_standard_ack";
45
46/// Wake-on-LAN protocol action handler.
47pub struct WolProtocol;
48
49impl WolProtocol {
50    pub fn new() -> Self {
51        Self
52    }
53}
54
55impl Default for WolProtocol {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl Protocol for WolProtocol {
62    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
63        vec![ParameterDefinition {
64            name: ALLOW_NON_STANDARD_ACK.to_string(),
65            type_hint: "boolean".to_string(),
66            description: "Allow the announce_host_awake action to actually send a datagram. \
67                          Wake-on-LAN has no response of any kind, so this is a NetGet \
68                          extension for lab and honeypot use, not part of the protocol. \
69                          Defaults to false, in which case announce_host_awake is refused \
70                          and logged and nothing is transmitted."
71                .to_string(),
72            required: false,
73            example: json!(false),
74            default: None,
75        }]
76    }
77
78    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
79        // Deliberately empty: see the module docs. A listener that never transmits has no
80        // user-triggered verb, and the one action that does transmit needs the receive
81        // loop's context.
82        Vec::new()
83    }
84
85    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
86        vec![
87            record_wake_request_action(),
88            ignore_magic_packet_action(),
89            announce_host_awake_action(),
90        ]
91    }
92
93    fn protocol_name(&self) -> &'static str {
94        "Wake-on-LAN"
95    }
96
97    fn get_event_types(&self) -> Vec<EventType> {
98        get_wol_event_types()
99    }
100
101    fn stack_name(&self) -> &'static str {
102        "ETH>IP>UDP>WOL"
103    }
104
105    fn keywords(&self) -> Vec<&'static str> {
106        vec!["wol", "wakeonlan", "wake-on-lan", "magic packet"]
107    }
108
109    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
110        use crate::protocol::metadata::{
111            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
112        };
113
114        ProtocolMetadataV2::builder()
115            // Every sender is a fresh UDP peer that nothing ever closes, so the 10-second
116            // idle sweep is what removes them. Without this flag they accumulate until the
117            // server stops.
118            .connectionless()
119            // Deliberately silent: Wake-on-LAN has no reply of any kind. A magic packet is a one-way
120            // broadcast, so there is nothing to answer with, and the model is only deciding what the
121            // server does about it.
122            .deliberately_silent()
123            .state(DevelopmentState::Experimental)
124            // Port 9 really is below 1024, so this preflight really fires. It is checked
125            // against the requested port, so a test on a high port needs no privileges.
126            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(9))
127            .well_known_udp_port(9)
128            .implementation(
129                "Hand-written magic packet decoder (no dependency): scans the datagram for \
130                 6x0xFF followed by the same MAC 16 times, at any offset, with an optional \
131                 4- or 6-byte SecureON trailer",
132            )
133            .llm_control(
134                "Which target MACs are recognised, what is recorded about each packet, and \
135                 (off by default) whether a non-standard awake announcement is sent",
136            )
137            .e2e_testing(
138                "REAL THIRD-PARTY SENDER: the `wakeonlan` Perl script (0.50), in \
139                 tests/server/wol/real_client_test.rs. NOT #[ignore]d and NOT skip-gated — the \
140                 test FAILS, naming `brew install wakeonlan`, when the binary is absent. It \
141                 asserts the server decoded wakeonlan's own 102 bytes as target_mac \
142                 00:11:22:33:44:55, transport=udp, sync_offset=0, password_length=0, with the \
143                 mock echoing the EVENT's fields back so a misdecode produces a wrong record \
144                 rather than passing quietly. Alongside it, decode_test.rs covers the decoder \
145                 exhaustively against packets assembled from AMD's Magic Packet specification \
146                 (offsets 1/7/20/137, a false sync stream, 4- and 6-byte SecureON trailers, \
147                 near-misses at 15 repetitions and single flipped bytes), and e2e_test.rs covers \
148                 the four byte-identical silences and the announce_host_awake gate. \
149                 THIS IS STILL NOT A BETA CASE, deliberately: Wake-on-LAN is one-way and defines \
150                 no reply, so wakeonlan sends and exits and NOTHING NetGet produces is ever \
151                 judged by a third-party implementation. That makes this a codec test with a \
152                 real generator rather than a session, and it cannot catch the failure that \
153                 matters most here — a decoder wrong by being too PERMISSIVE passes every test \
154                 in this directory. NOT PROVEN: the EtherType 0x0842 transport (no raw socket is \
155                 bound, so nothing can receive one), and that any real NIC would wake, NetGet \
156                 not being a NIC.",
157            )
158            .notes(
159                "UDP only. The EtherType 0x0842 form is NOT received off the wire - that \
160                 needs a raw socket and this feature carries no packet-capture dependency; \
161                 transport='ethernet' means the UDP payload was itself an encapsulated \
162                 Ethernet frame. Wake-on-LAN defines no response, so nothing is ever sent \
163                 back except the explicitly non-standard, off-by-default announce_host_awake. \
164                 Experimental NOT for want of a third-party sender - the real `wakeonlan` \
165                 script now drives the decoder in tests/server/wol/real_client_test.rs - but \
166                 because a one-way protocol admits no session to be evidence of: nothing this \
167                 server emits is ever read by an independent implementation, so 'works against \
168                 real clients' cannot be established here in the direction Beta means it",
169            )
170            .build()
171    }
172
173    fn description(&self) -> &'static str {
174        "Wake-on-LAN magic packet listener (UDP 9). Receive-only: nothing is sent back"
175    }
176
177    fn example_prompt(&self) -> &'static str {
178        "Wake-on-LAN on port 9. Record magic packets for 00:11:22:33:44:55 as 'lab-nas' and \
179         ignore every other MAC"
180    }
181
182    fn group_name(&self) -> &'static str {
183        "Network"
184    }
185
186    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
187        use crate::llm::actions::StartupExamples;
188
189        // Deterministic: recognise one MAC, drop everything else, with no LLM call at all.
190        let script = r#"import json, sys
191data = json.load(sys.stdin)
192event = data["event"]
193known = {"00:11:22:33:44:55": "lab-nas"}
194mac = str(event.get("target_mac", "")).upper()
195if data["event_type_id"] == "wol_magic_packet_received" and mac in known:
196    actions = [{"type": "record_wake_request",
197                "target_mac": mac,
198                "host": known[mac],
199                "note": "from %s" % event.get("source_address", "?")}]
200else:
201    actions = [{"type": "ignore_magic_packet", "reason": "unknown target MAC"}]
202print(json.dumps({"actions": actions}))"#;
203
204        StartupExamples::new(
205            // LLM mode
206            json!({
207                "type": "open_server",
208                "port": 9,
209                "base_stack": "wol",
210                "instruction": "Wake-on-LAN listener. Record magic packets for hosts in the \
211                                00:11:22 range and ignore all others"
212            }),
213            // Script mode
214            json!({
215                "type": "open_server",
216                "port": 9,
217                "base_stack": "wol",
218                "event_handlers": [{
219                    "event_pattern": "wol_magic_packet_received",
220                    "handler": {
221                        "type": "script",
222                        "language": "python",
223                        "code": script
224                    }
225                }]
226            }),
227            // Static mode: record every magic packet, no LLM call.
228            json!({
229                "type": "open_server",
230                "port": 9,
231                "base_stack": "wol",
232                "event_handlers": [{
233                    "event_pattern": "wol_magic_packet_received",
234                    "handler": {
235                        "type": "static",
236                        "actions": [{
237                            "type": "record_wake_request",
238                            "target_mac": "{{event.target_mac}}",
239                            "host": "unidentified"
240                        }]
241                    }
242                }]
243            }),
244        )
245    }
246}
247
248impl Server for WolProtocol {
249    fn spawn(
250        &self,
251        ctx: crate::protocol::SpawnContext,
252    ) -> std::pin::Pin<
253        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
254    > {
255        Box::pin(async move {
256            use crate::server::wol::WolServer;
257            WolServer::spawn_with_llm_actions(
258                ctx.legacy_listen_addr(),
259                ctx.llm_client,
260                ctx.state,
261                ctx.status_tx,
262                ctx.server_id,
263                ctx.startup_params,
264            )
265            .await
266        })
267    }
268
269    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
270        let action_type = action
271            .get("type")
272            .and_then(|v| v.as_str())
273            .context("Missing 'type' field in action")?;
274
275        match action_type {
276            RECORD_WAKE_REQUEST => execute_record_wake_request(&action),
277            IGNORE_MAGIC_PACKET => execute_ignore_magic_packet(&action),
278            ANNOUNCE_HOST_AWAKE => execute_announce_host_awake(&action),
279            _ => Err(anyhow::anyhow!(
280                "Unknown Wake-on-LAN action: {}",
281                action_type
282            )),
283        }
284    }
285}
286
287/// Record the packet: validate what the model claims and let the access log keep it.
288///
289/// NetGet protocols do not implement storage (see the root `CLAUDE.md`). The durable record
290/// is the access log entry written for every handled event, which already contains this action
291/// verbatim and is readable with `list_access_logs` / `get_access_log`. Nothing is written to
292/// disk or to a database here, and — Wake-on-LAN having no response — nothing goes on the wire
293/// either.
294fn execute_record_wake_request(action: &serde_json::Value) -> Result<ActionResult> {
295    let target_mac = action
296        .get("target_mac")
297        .and_then(|v| v.as_str())
298        .context("Missing 'target_mac' parameter (expected \"00:11:22:33:44:55\")")?;
299
300    validate_mac(target_mac)?;
301
302    let host = action.get("host").and_then(|v| v.as_str()).unwrap_or("");
303    tracing::debug!(
304        "Wake-on-LAN wake request recorded for {} ({})",
305        target_mac,
306        if host.is_empty() { "unlabelled" } else { host }
307    );
308
309    Ok(ActionResult::NoAction)
310}
311
312/// Drop the packet. The `reason` is what makes this distinguishable, in the access log, from
313/// the model having said nothing at all.
314fn execute_ignore_magic_packet(action: &serde_json::Value) -> Result<ActionResult> {
315    let reason = action
316        .get("reason")
317        .and_then(|v| v.as_str())
318        .unwrap_or("no reason given");
319    tracing::debug!("Wake-on-LAN magic packet ignored: {}", reason);
320    Ok(ActionResult::NoAction)
321}
322
323/// Validate a non-standard announcement without sending it.
324///
325/// The datagram is sent by the receive loop (`WolServer::process_announcements`), which owns
326/// both the `allow_non_standard_ack` gate and the magic packet's source address. This arm
327/// exists so the advertised action is executable — returning `NoAction` here means the send
328/// happens exactly once, in the one place that can decide whether it is allowed at all.
329fn execute_announce_host_awake(action: &serde_json::Value) -> Result<ActionResult> {
330    let target_mac = action
331        .get("target_mac")
332        .and_then(|v| v.as_str())
333        .context("Missing 'target_mac' parameter (expected \"00:11:22:33:44:55\")")?;
334
335    validate_mac(target_mac)?;
336
337    // Fail here rather than in the loop: an unparseable destination should be reported to
338    // whoever produced the action, not buried in the server log.
339    if let Some(target) = action.get("announce_to").and_then(|v| v.as_str()) {
340        resolve_announce_target(target)?;
341    }
342
343    tracing::debug!(
344        "Wake-on-LAN announce_host_awake accepted for {}; the send is gated on \
345         allow_non_standard_ack and performed by the receive loop",
346        target_mac
347    );
348
349    Ok(ActionResult::NoAction)
350}
351
352/// Accept `00:11:22:33:44:55` and `00-11-22-33-44-55`, reject anything else.
353///
354/// The MAC crosses the boundary to the model as a formatted string in both directions, so it
355/// is worth checking that what comes back is one — a model that answers with `unknown` or a
356/// partial address should be told, not silently recorded.
357fn validate_mac(mac: &str) -> Result<()> {
358    let octets: Vec<&str> = if mac.contains(':') {
359        mac.split(':').collect()
360    } else {
361        mac.split('-').collect()
362    };
363
364    let well_formed = octets.len() == 6
365        && octets
366            .iter()
367            .all(|o| o.len() == 2 && o.chars().all(|c| c.is_ascii_hexdigit()));
368
369    if well_formed {
370        Ok(())
371    } else {
372        Err(anyhow::anyhow!(
373            "Invalid 'target_mac' {mac:?}: expected six colon-separated hex octets, \
374             e.g. \"00:11:22:33:44:55\""
375        ))
376    }
377}
378
379/// Resolve an `announce_to` value ("HOST:PORT") to one socket address.
380pub fn resolve_announce_target(target: &str) -> Result<std::net::SocketAddr> {
381    use std::net::ToSocketAddrs;
382
383    target
384        .to_socket_addrs()
385        .with_context(|| {
386            format!(
387                "Invalid 'announce_to' {target:?}: expected \"HOST:PORT\", \
388                 e.g. \"192.168.1.10:9000\""
389            )
390        })?
391        .next()
392        .with_context(|| format!("'announce_to' {target:?} did not resolve to any address"))
393}
394
395// ============================================================================
396// Action definitions
397// ============================================================================
398
399fn record_wake_request_action() -> ActionDefinition {
400    ActionDefinition {
401        name: RECORD_WAKE_REQUEST.to_string(),
402        description: "Treat this magic packet as a wake request for a host you recognise. \
403                      Nothing is sent back - Wake-on-LAN has no response - and nothing is \
404                      written to disk. The action and its fields become part of the server's \
405                      access log entry for this event, readable later with list_access_logs \
406                      / get_access_log, and the operator sees it on the dashboard."
407            .to_string(),
408        parameters: vec![
409            Parameter {
410                name: "target_mac".to_string(),
411                type_hint: "string".to_string(),
412                description: "The MAC the packet is for, copied from the event's 'target_mac' \
413                              field. Six colon-separated hex octets, e.g. '00:11:22:33:44:55'"
414                    .to_string(),
415                required: true,
416            },
417            Parameter {
418                name: "host".to_string(),
419                type_hint: "string".to_string(),
420                description: "Your name for the machine this MAC belongs to, e.g. 'lab-nas'. \
421                              Free text; it is a label for the operator, not a lookup"
422                    .to_string(),
423                required: false,
424            },
425            Parameter {
426                name: "note".to_string(),
427                type_hint: "string".to_string(),
428                description: "Anything else worth recording about this packet - who sent it, \
429                              whether a SecureON password was present, why it looks legitimate \
430                              or suspicious"
431                    .to_string(),
432                required: false,
433            },
434        ],
435        example: json!({
436            "type": RECORD_WAKE_REQUEST,
437            "target_mac": "00:11:22:33:44:55",
438            "host": "lab-nas",
439            "note": "expected nightly wake from the backup host"
440        }),
441        log_template: Some(
442            LogTemplate::new()
443                .with_info("-> WoL recorded wake request for {target_mac}")
444                .with_debug("WoL record_wake_request: target_mac={target_mac} host={host}"),
445        ),
446    }
447}
448
449fn ignore_magic_packet_action() -> ActionDefinition {
450    ActionDefinition {
451        name: IGNORE_MAGIC_PACKET.to_string(),
452        description: "Drop this magic packet: it is not for a host you know about, or you do \
453                      not want it acted on. Give a reason - on a protocol with no response, \
454                      the log is the only place a deliberate drop can be told apart from \
455                      NetGet having failed to decide anything."
456            .to_string(),
457        parameters: vec![Parameter {
458            name: "reason".to_string(),
459            type_hint: "string".to_string(),
460            description: "Why this packet is being dropped, e.g. 'MAC not in the managed \
461                          range' or 'no SecureON password'"
462                .to_string(),
463            required: false,
464        }],
465        example: json!({
466            "type": IGNORE_MAGIC_PACKET,
467            "reason": "target MAC is not one of the hosts this server manages"
468        }),
469        log_template: Some(
470            LogTemplate::new()
471                .with_info("-> WoL ignored the magic packet")
472                .with_debug("WoL ignore_magic_packet: reason={reason}"),
473        ),
474    }
475}
476
477fn announce_host_awake_action() -> ActionDefinition {
478    ActionDefinition {
479        name: ANNOUNCE_HOST_AWAKE.to_string(),
480        description: "NOT PART OF WAKE-ON-LAN. Wake-on-LAN is one-way: a real NIC that wakes \
481                      its machine sends nothing back, and no sender waits for anything. This \
482                      action sends a plain UDP text datagram claiming the host is now awake, \
483                      for lab and honeypot setups that want a visible effect. It does nothing \
484                      unless the server was started with allow_non_standard_ack=true, in \
485                      which case the refusal is logged. Prefer record_wake_request."
486            .to_string(),
487        parameters: vec![
488            Parameter {
489                name: "target_mac".to_string(),
490                type_hint: "string".to_string(),
491                description: "The MAC being claimed awake, e.g. '00:11:22:33:44:55'".to_string(),
492                required: true,
493            },
494            Parameter {
495                name: "announce_to".to_string(),
496                type_hint: "string".to_string(),
497                description: "Where to send the datagram, as 'HOST:PORT'. Omit it to answer \
498                              the machine that sent the magic packet (the event's \
499                              'source_address')"
500                    .to_string(),
501                required: false,
502            },
503            Parameter {
504                name: "message".to_string(),
505                type_hint: "string".to_string(),
506                description: "Text of the datagram. Defaults to 'netget-wol: host <mac> is \
507                              awake'. Plain text only - no encoded bytes"
508                    .to_string(),
509                required: false,
510            },
511        ],
512        example: json!({
513            "type": ANNOUNCE_HOST_AWAKE,
514            "target_mac": "00:11:22:33:44:55",
515            "message": "netget-wol: host 00:11:22:33:44:55 is awake"
516        }),
517        log_template: Some(
518            // Deliberately "requested", not "announced": this template fires when the action
519            // is accepted, and the send happens later in the receive loop only if the gate
520            // allows it. Claiming the announcement went out here would be a lie whenever
521            // allow_non_standard_ack is false, which is the default.
522            LogTemplate::new()
523                .with_info(
524                    "-> WoL announce_host_awake requested for {target_mac} (NON-STANDARD; sent \
525                     only when allow_non_standard_ack is set)",
526                )
527                .with_debug("WoL announce_host_awake: target_mac={target_mac} to={announce_to}"),
528        ),
529    }
530}
531
532// ============================================================================
533// Event types
534// ============================================================================
535
536/// The only event this protocol raises, and the only thing there is to raise: a magic packet
537/// arrived.
538///
539/// A datagram that is *not* a magic packet raises nothing. Port 9 is the discard port and
540/// attracts scanners; there is no decision for the model to make about a stray datagram, so
541/// making it pay for an LLM call would be pure cost. Those are logged at DEBUG instead.
542pub static WOL_MAGIC_PACKET_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
543    EventType::new(
544        "wol_magic_packet_received",
545        "A valid Wake-on-LAN magic packet arrived: 6 bytes of 0xFF followed by the target MAC \
546         repeated 16 times. Wake-on-LAN defines NO response - a real NIC wakes its machine and \
547         sends nothing - so there is nothing to reply with. What you decide is whether this \
548         packet is for a host you recognise (record_wake_request) or not \
549         (ignore_magic_packet). Answering with neither is recorded as decision=model_silent",
550        json!({
551            "type": RECORD_WAKE_REQUEST,
552            "target_mac": "00:11:22:33:44:55",
553            "host": "lab-nas"
554        }),
555    )
556    .with_parameters(vec![
557        Parameter {
558            name: "target_mac".to_string(),
559            type_hint: "string".to_string(),
560            description: "The MAC the packet asks to wake, formatted as six uppercase \
561                          colon-separated hex octets, e.g. '00:11:22:33:44:55'. This is the \
562                          MAC that appeared 16 times in the payload"
563                .to_string(),
564            required: true,
565        },
566        Parameter {
567            name: "source_address".to_string(),
568            type_hint: "string".to_string(),
569            description: "IP:port the datagram was received from. Magic packets are normally \
570                          broadcast, so this is whoever sent it, not the machine being woken"
571                .to_string(),
572            required: true,
573        },
574        Parameter {
575            name: "has_password".to_string(),
576            type_hint: "boolean".to_string(),
577            description: "True when a SecureON password followed the 102-byte payload. The \
578                          password bytes themselves are deliberately not exposed"
579                .to_string(),
580            required: true,
581        },
582        Parameter {
583            name: "password_length".to_string(),
584            type_hint: "number".to_string(),
585            description: "0 when there was no SecureON password, otherwise 4 or 6 - the two \
586                          lengths the extension defines"
587                .to_string(),
588            required: true,
589        },
590        Parameter {
591            name: "transport".to_string(),
592            type_hint: "string".to_string(),
593            description: "'udp' when the magic packet was the UDP payload (the ordinary \
594                          case), or 'ethernet' when the payload was itself a complete \
595                          Ethernet frame with EtherType 0x0842 carrying the packet. NetGet \
596                          binds a UDP socket in both cases; it does not capture raw frames"
597                .to_string(),
598            required: true,
599        },
600        Parameter {
601            name: "sync_offset".to_string(),
602            type_hint: "number".to_string(),
603            description: "Byte offset within the datagram at which the six 0xFF bytes were \
604                          found. 0 for a bare magic packet, 14 for one inside an Ethernet \
605                          frame, anything else when the sender wrapped or padded it"
606                .to_string(),
607            required: true,
608        },
609    ])
610    .with_actions(vec![
611        record_wake_request_action(),
612        ignore_magic_packet_action(),
613        announce_host_awake_action(),
614    ])
615    .with_log_template(
616        LogTemplate::new()
617            .with_info("WoL magic packet for {target_mac} from {source_address}")
618            .with_debug(
619                "WoL magic packet for {target_mac} from {source_address} transport={transport} \
620                 offset={sync_offset} password={has_password}",
621            )
622            .with_trace("WoL: {json_pretty(.)}"),
623    )
624});
625
626pub fn get_wol_event_types() -> Vec<EventType> {
627    vec![WOL_MAGIC_PACKET_RECEIVED_EVENT.clone()]
628}