1use 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
34pub const RECORD_WAKE_REQUEST: &str = "record_wake_request";
36
37pub const IGNORE_MAGIC_PACKET: &str = "ignore_magic_packet";
39
40pub const ANNOUNCE_HOST_AWAKE: &str = "announce_host_awake";
42
43pub const ALLOW_NON_STANDARD_ACK: &str = "allow_non_standard_ack";
45
46pub 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 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 .connectionless()
119 .deliberately_silent()
123 .state(DevelopmentState::Experimental)
124 .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 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 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 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 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
287fn 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
312fn 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
323fn 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 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
352fn 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
379pub 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
395fn 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 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
532pub 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}