Skip to main content

netget/server/wsdiscovery/
actions.rs

1//! What the model answers when a WS-Discovery client probes or resolves. A target service
2//! answers only with a match: there is no negative reply in WS-Discovery, so saying nothing is
3//! how a target declines. Announcements (Hello, Bye) go to the multicast group.
4use 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/// What an answer needs from the request it answers.
19#[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        // A datagram server owns no socket in its registry instance; announcements are made
255        // as answers (wsd_send_hello / wsd_send_bye), routed to the group by the server.
256        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        // Loopback by default like every server here; host 0.0.0.0 is what lets multicast
275        // probes from the network arrive. The port is 0 so `protocol::default_port` applies the
276        // declared well-known 3702 (or an OS-assigned one when 3702 cannot be bound).
277        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                    // The registry's instance answers no request: the shape is checked, and
366                    // nothing is sent.
367                    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}