1use super::wire::{self, Kind, Version};
5use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
10use crate::state::app_state::AppState;
11use anyhow::{bail, Context, Result};
12use serde_json::{json, Value};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, LazyLock};
15
16pub const ANNOUNCE: &str = "wsd_announce";
17
18#[derive(Clone)]
20pub struct RequestContext {
21 pub version: Version,
22 pub message_id: String,
23 pub instance_id: u64,
24 pub numbers: Arc<AtomicU64>,
25}
26
27#[derive(Default, Clone)]
28pub struct WsDiscoveryProtocol {
29 request: Option<RequestContext>,
30}
31
32impl WsDiscoveryProtocol {
33 pub fn new() -> Self {
34 Self::default()
35 }
36 pub fn for_request(ctx: RequestContext) -> Self {
37 Self { request: Some(ctx) }
38 }
39}
40
41pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
42 Parameter {
43 name: name.into(),
44 type_hint: type_hint.into(),
45 description: description.into(),
46 required,
47 }
48}
49
50pub fn action(
51 name: &str,
52 description: &str,
53 parameters: Vec<Parameter>,
54 example: Value,
55) -> ActionDefinition {
56 let info = match name {
57 "wsd_probe_match" => "-> WS-Discovery ProbeMatches".to_string(),
58 "wsd_resolve_match" => "-> WS-Discovery ResolveMatch {endpoint_reference}".to_string(),
59 "wsd_send_hello" => "-> WS-Discovery Hello {endpoint_reference}".to_string(),
60 "wsd_send_bye" => "-> WS-Discovery Bye {endpoint_reference}".to_string(),
61 _ => format!("-> WS-Discovery {}", name.trim_start_matches("wsd_")),
62 };
63 ActionDefinition {
64 name: name.into(),
65 description: description.into(),
66 parameters,
67 example,
68 log_template: Some(LogTemplate::new().with_info(info)),
69 }
70}
71
72pub fn target_params() -> Vec<Parameter> {
73 vec![
74 p("endpoint_reference", "string", "The service's stable identity, a URI such as urn:uuid:5a7c...", true),
75 p("types", "array", "Service types as {namespace}LocalName, e.g. {http://www.onvif.org/ver10/network/wsdl}NetworkVideoTransmitter", false),
76 p("scopes", "array", "Scope URIs, e.g. onvif://www.onvif.org/name/Camera1", false),
77 p("xaddrs", "array", "Transport addresses where the service is reached, e.g. http://192.0.2.10/onvif/device_service", false),
78 p("metadata_version", "number", "Incremented when the service's metadata changes (default 1)", false),
79 ]
80}
81
82fn example_target() -> Value {
83 json!({"endpoint_reference":"urn:uuid:9d8f7e6c-0000-4000-8000-000000000001",
84 "types":["{http://www.onvif.org/ver10/network/wsdl}NetworkVideoTransmitter"],
85 "scopes":["onvif://www.onvif.org/name/Camera1"],
86 "xaddrs":["http://192.0.2.10/onvif/device_service"],
87 "metadata_version":1})
88}
89
90pub fn probe_match_action() -> ActionDefinition {
91 let mut example = json!({"type":"wsd_probe_match"});
92 example["matches"] = json!([example_target()]);
93 action(
94 "wsd_probe_match",
95 "Answer the probe with ProbeMatches, one entry per service of yours that matches. To decline, answer nothing: WS-Discovery has no negative reply.",
96 vec![p("matches", "array", "The matching services, each with endpoint_reference, types, scopes, xaddrs and metadata_version", true)],
97 example,
98 )
99}
100
101pub fn resolve_match_action() -> ActionDefinition {
102 let mut example = example_target();
103 example["type"] = json!("wsd_resolve_match");
104 action(
105 "wsd_resolve_match",
106 "Answer the resolve with this service's current transport addresses.",
107 target_params(),
108 example,
109 )
110}
111
112pub fn hello_action() -> ActionDefinition {
113 let mut example = example_target();
114 example["type"] = json!("wsd_send_hello");
115 action(
116 "wsd_send_hello",
117 "Announce a service to the multicast group (Hello).",
118 target_params(),
119 example,
120 )
121}
122
123pub fn bye_action() -> ActionDefinition {
124 action(
125 "wsd_send_bye",
126 "Announce to the multicast group that a service is leaving (Bye).",
127 vec![p(
128 "endpoint_reference",
129 "string",
130 "The leaving service's identity URI, e.g. urn:uuid:...",
131 true,
132 )],
133 json!({"type":"wsd_send_bye","endpoint_reference":"urn:uuid:9d8f7e6c-0000-4000-8000-000000000001"}),
134 )
135}
136
137fn probe_params() -> Vec<Parameter> {
138 vec![
139 p(
140 "version",
141 "string",
142 "WS-Discovery version the client spoke: 2005/04 or 2009/01",
143 true,
144 ),
145 p(
146 "message_id",
147 "string",
148 "The probe's MessageID, which the answer relates to",
149 true,
150 ),
151 p(
152 "types",
153 "array",
154 "Types asked for, as {namespace}LocalName; empty means any",
155 true,
156 ),
157 p(
158 "scopes",
159 "array",
160 "Scope URIs asked for; empty means any",
161 true,
162 ),
163 p(
164 "match_by",
165 "string",
166 "The scope matching rule URI, when the client named one",
167 false,
168 ),
169 p("source", "string", "The client's address and port", true),
170 ]
171}
172
173pub static PROBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
174 EventType::new(
175 "wsd_probe",
176 "A client is looking for services with these types and scopes. Answer wsd_probe_match for each of yours that matches, or nothing.",
177 probe_match_action().example.clone(),
178 )
179 .with_parameters(probe_params())
180 .with_actions(vec![probe_match_action(), hello_action(), bye_action()])
181});
182
183pub static RESOLVE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
184 EventType::new(
185 "wsd_resolve",
186 "A client wants the transport addresses of one service. Answer wsd_resolve_match if it is yours, or nothing.",
187 resolve_match_action().example.clone(),
188 )
189 .with_parameters(vec![
190 p("version", "string", "WS-Discovery version the client spoke: 2005/04 or 2009/01", true),
191 p("endpoint_reference", "string", "The service asked about, a URI such as urn:uuid:...", true),
192 p("source", "string", "The client's address and port", true),
193 ])
194 .with_actions(vec![resolve_match_action(), hello_action(), bye_action()])
195});
196
197pub static ANNOUNCEMENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
198 EventType::new(
199 "wsd_announcement",
200 "Another service announced itself (Hello) or its departure (Bye) on the group.",
201 hello_action().example.clone(),
202 )
203 .with_parameters(vec![
204 p(
205 "message",
206 "string",
207 "Hello or Bye, as the announcing service sent it",
208 true,
209 ),
210 p(
211 "service",
212 "object",
213 "The announced service: endpoint_reference, types, scopes, xaddrs",
214 true,
215 ),
216 p("source", "string", "The announcer's address and port", true),
217 ])
218 .with_actions(vec![hello_action(), bye_action()])
219});
220
221impl WsDiscoveryProtocol {
222 fn next_number(&self) -> (u64, u64) {
223 match &self.request {
224 Some(r) => (r.instance_id, r.numbers.fetch_add(1, Ordering::Relaxed)),
225 None => (super::instance_id(), 1),
226 }
227 }
228 fn version(&self) -> Version {
229 self.request.as_ref().map_or(Version::V2005, |r| r.version)
230 }
231}
232
233impl Protocol for WsDiscoveryProtocol {
234 fn protocol_name(&self) -> &'static str {
235 "WS-Discovery"
236 }
237 fn stack_name(&self) -> &'static str {
238 "ETH>IP>UDP>WS-Discovery"
239 }
240 fn keywords(&self) -> Vec<&'static str> {
241 vec![
242 "ws-discovery",
243 "wsdiscovery",
244 "wsd",
245 "onvif discovery",
246 "soap-over-udp",
247 "3702",
248 ]
249 }
250 fn description(&self) -> &'static str {
251 "WS-Discovery target service: answers Probe and Resolve over SOAP/UDP multicast (ONVIF cameras, WSD printers, Windows network discovery) and announces Hello/Bye"
252 }
253 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
254 Vec::new()
257 }
258 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
259 vec![
260 probe_match_action(),
261 resolve_match_action(),
262 hello_action(),
263 bye_action(),
264 ]
265 }
266 fn get_event_types(&self) -> Vec<EventType> {
267 vec![
268 PROBE_EVENT.clone(),
269 RESOLVE_EVENT.clone(),
270 ANNOUNCEMENT_EVENT.clone(),
271 ]
272 }
273 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
274 Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
278 }
279
280 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
281 vec![
282 ParameterDefinition {
283 name: "join_multicast".into(),
284 type_hint: "boolean".into(),
285 description: "Join 239.255.255.250 to hear multicast probes; directed (unicast) probes are answered either way".into(),
286 required: false,
287 example: json!(false),
288 default: Some(json!(super::DEFAULT_JOIN_MULTICAST)),
289 },
290 ParameterDefinition {
291 name: "multicast_interface".into(),
292 type_hint: "string".into(),
293 description: "IPv4 address of the interface to join the group on (default: the system's choice)".into(),
294 required: false,
295 example: json!("192.0.2.10"),
296 default: None,
297 },
298 ParameterDefinition {
299 name: "announce_target".into(),
300 type_hint: "string".into(),
301 description: "Where Hello and Bye go instead of 239.255.255.250:3702, as ip:port".into(),
302 required: false,
303 example: json!("127.0.0.1:3702"),
304 default: None,
305 },
306 ]
307 }
308 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
309 use crate::protocol::metadata::*;
310 ProtocolMetadataV2::builder()
311 .state(DevelopmentState::Experimental)
312 .privilege_requirement(PrivilegeRequirement::None)
313 .well_known_udp_port(3702)
314 .connectionless()
315 .deliberately_silent()
316 .implementation("Hand-rolled SOAP-over-UDP (src/server/wsdiscovery/wire.rs, quick-xml): Probe, ProbeMatches, Resolve, ResolveMatches, Hello and Bye in the April 2005 (Windows, ONVIF, wsdd) and OASIS 2009 versions, answered in the version asked; types in Clark notation; SO_REUSEADDR so it shares port 3702 with other discovery daemons on the host")
317 .llm_control("Which probes and resolves to answer and with which services (types, scopes, addresses), and which Hello/Bye announcements to make")
318 .e2e_testing("tests/server/wsdiscovery: python WSDiscovery (directed and multicast probes, the resolve it sends for a match without addresses, and both protocol versions by raw socket); raw tests for the bounds")
319 .notes("DELIBERATELY SILENT: WS-Discovery has no negative reply, so a probe the model does not answer, or a backend failure, sends nothing (logged decision=model_silent / fail_closed_*). Each answer goes unicast to the asker. No WS-Discovery proxy mode, no metadata exchange (WS-Transfer Get) — that is HTTP on the XAddrs.")
320 .max_inbound_bytes(wire::MAX_DATAGRAM)
321 .build()
322 }
323 fn example_prompt(&self) -> &'static str {
324 "Pretend to be an ONVIF camera on the network: answer WS-Discovery probes for NetworkVideoTransmitter"
325 }
326 fn get_startup_examples(&self) -> StartupExamples {
327 let base = json!({"type":"open_server","base_stack":"wsdiscovery","port":0,"startup_params":{"join_multicast":false},
328 "instruction":"You are an ONVIF camera urn:uuid:9d8f7e6c-0000-4000-8000-000000000001 at http://192.0.2.10/onvif/device_service. Answer probes for NetworkVideoTransmitter or for any type."});
329 let mut static_example = base.clone();
330 static_example["event_handlers"] = json!([{"event_pattern":"wsd_probe","handler":{"type":"static","actions":[probe_match_action().example]}}]);
331 let mut scripted = base.clone();
332 scripted["event_handlers"] = json!([{"event_pattern":"wsd_probe","handler":{"type":"script","language":"python",
333 "code":"import json,sys\ne=json.load(sys.stdin)['event']\nm={'endpoint_reference':'urn:uuid:9d8f7e6c-0000-4000-8000-000000000001','types':['{http://www.onvif.org/ver10/network/wsdl}NetworkVideoTransmitter'],'xaddrs':['http://192.0.2.10/onvif/device_service']}\nok=not e['types'] or m['types'][0] in e['types']\nprint(json.dumps({'actions':[{'type':'wsd_probe_match','matches':[m]}] if ok else []}))"}}]);
334 StartupExamples::new(base, scripted, static_example)
335 }
336 fn group_name(&self) -> &'static str {
337 "Discovery"
338 }
339}
340
341impl Server for WsDiscoveryProtocol {
342 fn spawn(
343 &self,
344 ctx: SpawnContext,
345 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
346 {
347 Box::pin(super::spawn(ctx))
348 }
349 fn execute_action(&self, action: Value) -> Result<ActionResult> {
350 let kind = action["type"].as_str().unwrap_or_default();
351 let version = self.version();
352 match kind {
353 "wsd_probe_match" | "wsd_resolve_match" => {
354 let targets = if kind == "wsd_probe_match" {
355 let list = action["matches"]
356 .as_array()
357 .context("wsd_probe_match needs matches, an array of services")?;
358 list.iter()
359 .map(wire::target_from_json)
360 .collect::<Result<Vec<_>>>()?
361 } else {
362 vec![wire::target_from_json(&action)?]
363 };
364 let Some(request) = &self.request else {
365 return Ok(ActionResult::NoAction);
368 };
369 let (instance, number) = self.next_number();
370 let message_kind = if kind == "wsd_probe_match" {
371 Kind::ProbeMatches
372 } else {
373 Kind::ResolveMatches
374 };
375 let xml = wire::matches(
376 version,
377 message_kind,
378 &request.message_id,
379 &targets,
380 instance,
381 number,
382 )?;
383 Ok(ActionResult::Output(xml.into_bytes()))
384 }
385 "wsd_send_hello" => {
386 let target = wire::target_from_json(&action)?;
387 let (instance, number) = self.next_number();
388 Ok(ActionResult::Custom {
389 name: ANNOUNCE.into(),
390 data: json!({"xml": wire::hello(version, &target, instance, number)}),
391 })
392 }
393 "wsd_send_bye" => {
394 let endpoint = action["endpoint_reference"]
395 .as_str()
396 .context("wsd_send_bye needs endpoint_reference")?;
397 let (instance, number) = self.next_number();
398 Ok(ActionResult::Custom {
399 name: ANNOUNCE.into(),
400 data: json!({"xml": wire::bye(version, endpoint, instance, number)}),
401 })
402 }
403 other => bail!("Unknown WS-Discovery action {other:?}"),
404 }
405 }
406}