Skip to main content

netget/client/h323/
actions.rs

1//! What the model can do as an H.323 endpoint that places calls: register (and re-register or
2//! unregister) with a gatekeeper, place a call — admission first when registered — and hang
3//! up. Every result comes back decoded: the gatekeeper's confirm or reject with its reason,
4//! the call's progress, the codec and media addresses agreed, and who released it and why.
5use crate::llm::actions::{
6    client_trait::{Client, ClientActionResult},
7    protocol_trait::Protocol,
8    ActionDefinition, ParameterDefinition, StartupExamples,
9};
10use crate::protocol::log_template::LogTemplate;
11use crate::protocol::{ConnectContext, EventType};
12use crate::server::h323::actions::{codecs_parameter, display_of, p};
13use crate::server::h323::{call, gatekeeper, h225};
14use crate::state::app_state::AppState;
15use anyhow::{anyhow, bail, Context, Result};
16use serde_json::{json, Value};
17use std::net::SocketAddr;
18use std::sync::LazyLock;
19
20pub const PLACE_CALL: &str = "h323_place_call";
21pub const HANGUP: &str = "h323_hangup";
22pub const REGISTER: &str = "h323_register";
23pub const UNREGISTER: &str = "h323_unregister";
24
25/// The alias NetGet registers under when none is configured.
26pub const DEFAULT_ALIAS: &str = "h323-ID:NetGet";
27/// What the remote address is when `mode` is not given: decided by its port.
28pub const DEFAULT_MODE: &str = "auto";
29/// Whether to send GRQ before RRQ when none is configured.
30pub const DEFAULT_DISCOVER: bool = true;
31
32#[derive(Debug, Clone)]
33pub enum ClientCommand {
34    PlaceCall {
35        destination: String,
36        codecs: Vec<crate::server::h323::h245::Codec>,
37        display: Option<String>,
38    },
39    Hangup {
40        cause: u8,
41        reason: Option<String>,
42    },
43    Register {
44        aliases: Option<Vec<Value>>,
45        ttl: Option<u64>,
46    },
47    Unregister,
48}
49
50impl ClientCommand {
51    pub fn name(&self) -> &'static str {
52        match self {
53            ClientCommand::PlaceCall { .. } => PLACE_CALL,
54            ClientCommand::Hangup { .. } => HANGUP,
55            ClientCommand::Register { .. } => REGISTER,
56            ClientCommand::Unregister => UNREGISTER,
57        }
58    }
59}
60
61pub fn parse_command(a: &Value) -> Result<ClientCommand> {
62    let kind = a
63        .get("type")
64        .and_then(|t| t.as_str())
65        .ok_or_else(|| anyhow!("missing 'type'"))?;
66    Ok(match kind {
67        PLACE_CALL => {
68            let destination = a
69                .get("destination")
70                .and_then(|d| d.as_str())
71                .context("destination (an alias, a number or ip:port) is required")?
72                .to_string();
73            if destination.parse::<SocketAddr>().is_err() {
74                h225::alias(&destination)?;
75            }
76            ClientCommand::PlaceCall {
77                destination,
78                codecs: call::codecs_from(a.get("codec"))?,
79                display: display_of(a)?,
80            }
81        }
82        HANGUP => {
83            let (cause, reason) = crate::server::h323::actions::release_of(a)?;
84            ClientCommand::Hangup { cause, reason }
85        }
86        REGISTER => ClientCommand::Register {
87            aliases: match a.get("aliases") {
88                None | Some(Value::Null) => None,
89                Some(v) => Some(h225::aliases(v)?),
90            },
91            ttl: match a.get("time_to_live") {
92                None | Some(Value::Null) => None,
93                Some(t) => Some(
94                    t.as_u64()
95                        .filter(|t| (1..=gatekeeper::MAX_TTL_SECS).contains(t))
96                        .ok_or_else(|| {
97                            anyhow!("time_to_live is 1 to {} seconds", gatekeeper::MAX_TTL_SECS)
98                        })?,
99                ),
100            },
101        },
102        UNREGISTER => ClientCommand::Unregister,
103        other => bail!("{other} is not an H.323 client action"),
104    })
105}
106
107fn action(
108    name: &str,
109    description: &str,
110    parameters: Vec<crate::llm::actions::Parameter>,
111    example: Value,
112) -> ActionDefinition {
113    ActionDefinition {
114        name: name.into(),
115        description: description.into(),
116        parameters,
117        example,
118        log_template: Some(
119            LogTemplate::new()
120                .with_info(format!("H.323 {name}"))
121                .with_debug(format!("H.323 {name}: {{json(.)}}")),
122        ),
123    }
124}
125
126pub fn client_actions() -> Vec<ActionDefinition> {
127    vec![
128        action(
129            PLACE_CALL,
130            "Place a call. Registered with a gatekeeper, NetGet asks admission first (ARQ) and \
131             calls the address the ACF names; otherwise it calls the remote endpoint directly. \
132             It offers G.711 by fastStart and tunnels H.245. The outcome arrives as \
133             h323_call_connected, h323_call_released, h323_call_forwarded or h323_call_failed. \
134             One call at a time.",
135            vec![
136                p(
137                    "destination",
138                    "string",
139                    "Who to call: an alias (h323-ID:bob), a number (1002) or ip:port",
140                    true,
141                ),
142                p(
143                    "codec",
144                    "string",
145                    "g711u or g711a, or a list in order of preference (default both)",
146                    false,
147                ),
148                p(
149                    "display",
150                    "string",
151                    "Display name sent in the Setup (ASCII, up to 80 characters)",
152                    false,
153                ),
154            ],
155            json!({"type": PLACE_CALL, "destination": "1002"}),
156        ),
157        action(
158            HANGUP,
159            "Hang up the current call with Release Complete (and tell the gatekeeper, DRQ).",
160            vec![
161                p(
162                    "cause",
163                    "number",
164                    "Q.850 cause (default 16, normal clearing)",
165                    false,
166                ),
167                p(
168                    "reason",
169                    "string",
170                    "H.225.0 release reason, e.g. undefinedReason",
171                    false,
172                ),
173            ],
174            json!({"type": HANGUP}),
175        ),
176        action(
177            REGISTER,
178            "Register (again) with the gatekeeper (RRQ), optionally under other aliases. The \
179             result arrives as h323_registration.",
180            vec![
181                p(
182                    "aliases",
183                    "array",
184                    "Aliases as kind:value or bare values (default: the configured ones)",
185                    false,
186                ),
187                p(
188                    "time_to_live",
189                    "number",
190                    "Time-to-live to ask for, in seconds",
191                    false,
192                ),
193            ],
194            json!({"type": REGISTER, "aliases": ["h323-ID:netget", "1003"]}),
195        ),
196        action(
197            UNREGISTER,
198            "Unregister from the gatekeeper (URQ). The result arrives as h323_registration.",
199            vec![],
200            json!({"type": UNREGISTER}),
201        ),
202    ]
203}
204
205pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
206    EventType::new(
207        "h323_client_ready",
208        "The client is up. With a gatekeeper, registration has run and its result is here \
209         (confirmed or rejected, with the reason). Place a call with h323_place_call, \
210         (re)register, or do nothing",
211        json!({"type": PLACE_CALL, "destination": "1002"}),
212    )
213    .with_parameters(vec![
214        p(
215            "mode",
216            "string",
217            "gatekeeper or endpoint: what the remote address is",
218            true,
219        ),
220        p("remote", "string", "The remote address", true),
221        p(
222            "gatekeeper",
223            "object",
224            "GCF/GRJ: the gatekeeper's identifier and RAS address, or the reject reason",
225            false,
226        ),
227        p(
228            "registration",
229            "object",
230            "RCF/RRJ: endpoint identifier, aliases, time-to-live, or the reject reason",
231            false,
232        ),
233        p(
234            "local_call_signal_address",
235            "string",
236            "Where this endpoint takes calls",
237            false,
238        ),
239    ])
240    .with_actions(client_actions())
241    .with_log_template(
242        LogTemplate::new()
243            .with_info("H.323 client ready ({mode}) at {remote}")
244            .with_debug("H.323 client ready: {json(.)}"),
245    )
246});
247
248pub static REGISTRATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
249    EventType::new(
250        "h323_registration",
251        "The gatekeeper answered a registration or unregistration request (RCF/RRJ, UCF/URJ), \
252         or unregistered this endpoint itself (URQ)",
253        json!({"type": PLACE_CALL, "destination": "1002"}),
254    )
255    .with_parameters(vec![
256        p(
257            "request",
258            "string",
259            "RRQ, URQ, or gatekeeper_urq when the gatekeeper ended it",
260            true,
261        ),
262        p("result", "string", "confirmed, rejected or timeout", true),
263        p("reason", "string", "The reject reason, if rejected", false),
264        p(
265            "endpoint_id",
266            "string",
267            "The endpoint identifier assigned",
268            false,
269        ),
270        p("aliases", "array", "The aliases registered", false),
271        p(
272            "time_to_live",
273            "number",
274            "Seconds before NetGet must refresh it",
275            false,
276        ),
277    ])
278    .with_actions(client_actions())
279    .with_log_template(LogTemplate::new().with_info("H.323 {request}: {result} {reason}"))
280});
281
282pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
283    EventType::new(
284        "h323_call_connected",
285        "The called endpoint answered (Connect). The codec and media addresses agreed and how \
286         are in media. Hang up with h323_hangup, or leave the call up",
287        json!({"type": HANGUP}),
288    )
289    .with_parameters(vec![
290        p(
291            "destination",
292            "string",
293            "The destination the call was placed to: an alias, a number or ip:port",
294            true,
295        ),
296        p(
297            "address",
298            "string",
299            "The call signalling address called",
300            true,
301        ),
302        p(
303            "progress",
304            "array",
305            "Messages the callee sent before Connect",
306            true,
307        ),
308        p(
309            "call",
310            "object",
311            "crv, call and conference identifiers, H.245 state and media",
312            true,
313        ),
314        p(
315            "admission",
316            "object",
317            "The gatekeeper's ACF, when admission ran",
318            false,
319        ),
320    ])
321    .with_actions(client_actions())
322    .with_log_template(LogTemplate::new().with_info("H.323 call to {destination} connected"))
323});
324
325pub static RELEASED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
326    EventType::new(
327        "h323_call_released",
328        "The call ended: the callee released it (Release Complete, with its Q.850 cause and \
329         H.225.0 reason), this client hung up, or the connection closed",
330        json!({"type": PLACE_CALL, "destination": "1002"}),
331    )
332    .with_parameters(vec![
333        p(
334            "destination",
335            "string",
336            "The destination the call was placed to: an alias, a number or ip:port",
337            true,
338        ),
339        p("released_by", "string", "remote, local or connection", true),
340        p("cause", "number", "Q.850 cause, if any", false),
341        p("reason", "string", "H.225.0 release reason, if any", false),
342        p(
343            "reached",
344            "string",
345            "How far the call got: offered, proceeding, alerting, connected",
346            true,
347        ),
348        p("progress", "array", "Messages the callee sent", true),
349        p(
350            "call",
351            "object",
352            "The call's identifiers, H.245 state and media",
353            true,
354        ),
355        p(
356            "disengage",
357            "string",
358            "The gatekeeper's answer to DRQ, when registered",
359            false,
360        ),
361    ])
362    .with_actions(client_actions())
363    .with_log_template(
364        LogTemplate::new()
365            .with_info("H.323 call to {destination} released by {released_by}, cause {cause}"),
366    )
367});
368
369pub static FAILED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
370    EventType::new(
371        "h323_call_failed",
372        "A call could not be placed: the gatekeeper refused admission (ARJ, with its reason), \
373         did not answer, or the destination could not be reached",
374        json!({"type": PLACE_CALL, "destination": "1002"}),
375    )
376    .with_parameters(vec![
377        p("destination", "string", "The destination the call was placed to: an alias, a number or ip:port", true),
378        p("stage", "string", "admission, connect or signalling", true),
379        p("reason", "string", "Why the call could not be placed: the ARJ reason, no answer from the gatekeeper, or the connect error", true),
380    ])
381    .with_actions(client_actions())
382    .with_log_template(LogTemplate::new().with_info("H.323 call to {destination} failed at {stage}: {reason}"))
383});
384
385pub static FORWARDED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
386    EventType::new(
387        "h323_call_forwarded",
388        "The callee deflected the call (Facility callForwarded / routeCallToGatekeeper) and \
389         named somewhere else to call. Call it with h323_place_call, or do nothing",
390        json!({"type": PLACE_CALL, "destination": "1002"}),
391    )
392    .with_parameters(vec![
393        p(
394            "destination",
395            "string",
396            "The destination the call was placed to: an alias, a number or ip:port",
397            true,
398        ),
399        p(
400            "forward_address",
401            "string",
402            "The address named, if any",
403            false,
404        ),
405        p(
406            "forward_aliases",
407            "array",
408            "The aliases named, if any",
409            true,
410        ),
411    ])
412    .with_actions(client_actions())
413    .with_log_template(LogTemplate::new().with_info("H.323 call to {destination} forwarded"))
414});
415
416pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
417    EventType::new(
418        "h323_error",
419        "An action could not be carried out; nothing was sent for it",
420        json!({"type": PLACE_CALL, "destination": "1002"}),
421    )
422    .with_parameters(vec![
423        p("action", "string", "The action's type", true),
424        p("message", "string", "Why it was refused", true),
425    ])
426    .with_actions(client_actions())
427    .with_log_template(LogTemplate::new().with_info("H.323 {action} refused: {message}"))
428});
429
430pub static DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
431    EventType::new(
432        "h323_client_stopped",
433        "The client stopped. Nothing more can be sent",
434        json!({"type": "show_message", "message": "H.323 client stopped"}),
435    )
436    .with_parameters(vec![p(
437        "reason",
438        "string",
439        "Why the client stopped: the operator stopped it, or its session ended",
440        true,
441    )])
442    .with_no_actions()
443    .with_log_template(LogTemplate::new().with_info("H.323 client stopped: {reason}"))
444});
445
446#[derive(Default)]
447pub struct H323ClientProtocol;
448
449impl H323ClientProtocol {
450    pub fn new() -> Self {
451        Self
452    }
453}
454
455impl Protocol for H323ClientProtocol {
456    fn protocol_name(&self) -> &'static str {
457        "H323"
458    }
459    fn stack_name(&self) -> &'static str {
460        "ETH>IP>TCP>TPKT>Q.931>H.225.0"
461    }
462    fn keywords(&self) -> Vec<&'static str> {
463        vec![
464            "h323",
465            "h.323",
466            "h.225",
467            "q.931",
468            "gatekeeper",
469            "h323 client",
470        ]
471    }
472    fn description(&self) -> &'static str {
473        "H.323 endpoint that registers with a gatekeeper (GRQ/RRQ), asks admission (ARQ), places calls with fastStart G.711 and tunnelled H.245, and hangs up, reporting every result decoded"
474    }
475    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
476        client_actions()
477    }
478    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
479        vec![]
480    }
481    fn get_event_types(&self) -> Vec<EventType> {
482        vec![
483            READY_EVENT.clone(),
484            REGISTRATION_EVENT.clone(),
485            CONNECTED_EVENT.clone(),
486            RELEASED_EVENT.clone(),
487            FAILED_EVENT.clone(),
488            FORWARDED_EVENT.clone(),
489            ERROR_EVENT.clone(),
490            DISCONNECTED_EVENT.clone(),
491        ]
492    }
493    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
494        vec![
495            ParameterDefinition {
496                name: "mode".into(),
497                type_hint: "string".into(),
498                description: "What remote_addr is: gatekeeper (register and ask admission, RAS on \
499                              UDP), endpoint (call it directly), or auto (gatekeeper on port 1719, \
500                              endpoint otherwise)"
501                    .into(),
502                required: false,
503                example: json!("gatekeeper"),
504                default: Some(json!(DEFAULT_MODE)),
505            },
506            ParameterDefinition {
507                name: "aliases".into(),
508                type_hint: "array".into(),
509                description: "Aliases to register under and call from, as kind:value \
510                              (h323-ID:alice, dialledDigits:1001) or bare values"
511                    .into(),
512                required: false,
513                example: json!(["h323-ID:netget", "1003"]),
514                default: Some(json!([DEFAULT_ALIAS])),
515            },
516            ParameterDefinition {
517                name: "time_to_live".into(),
518                type_hint: "number".into(),
519                description: "Registration time-to-live to ask the gatekeeper for, in seconds"
520                    .into(),
521                required: false,
522                example: json!(120),
523                default: Some(json!(gatekeeper::DEFAULT_TTL_SECS)),
524            },
525            ParameterDefinition {
526                name: "discover".into(),
527                type_hint: "boolean".into(),
528                description: "Send GRQ before registering (some gatekeepers require discovery)"
529                    .into(),
530                required: false,
531                example: json!(false),
532                default: Some(json!(DEFAULT_DISCOVER)),
533            },
534            codecs_parameter(),
535        ]
536    }
537    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
538        use crate::protocol::metadata::*;
539        ProtocolMetadataV2::builder()
540            .state(DevelopmentState::Experimental)
541            .privilege_requirement(PrivilegeRequirement::None)
542            .implementation("The H.323 server's PER codec, Q.931 framing and call leg as the calling side: GRQ, RRQ (full, then keep-alive before the time-to-live runs out), ARQ, DRQ and URQ over a RAS socket, Setup with fastStart G.711 proposals and tunnelled H.245 over TPKT, Release Complete; IRQ from the gatekeeper answered with IRR, its URQ with UCF. Incoming calls on the registered call signalling address are refused")
543            .llm_control("When to register and under which aliases, who to call (alias, number or address) in which codec, when to hang up, and what to do after a reject, a forward or a release")
544            .e2e_testing("tests/client/h323: GNU Gatekeeper (H323Plus) confirms NetGet's GRQ and RRQ, lists it in its status port's registrations and admits its ARQ; Asterisk's chan_ooh323 (ooh323c) and H323Plus's simph323 answer NetGet's Setup and the call connects with fastStart media; tshark's h225, q931 and h245 dissectors read every message both ways")
545            .notes("Experimental: one gatekeeper implementation (GNU Gatekeeper) and two call peers (ooh323c, H323Plus) drive it in tests that fail rather than skip, but nobody has reviewed it against H.225.0 and its pair job had not run in CI when it landed. One call at a time. No H.235 security, no separate H.245 channel, G.711 only, no media sent.")
546            .build()
547    }
548    fn example_prompt(&self) -> &'static str {
549        "Register with the H.323 gatekeeper at 127.0.0.1:1719 as 1003, call 1002, and hang up once it connects"
550    }
551    fn get_startup_examples(&self) -> StartupExamples {
552        let llm = json!({"type": "open_client", "protocol": "H323", "remote_addr": "127.0.0.1:1719",
553            "startup_params": {"aliases": ["1003"]},
554            "instruction": "Once registered, call 1002. When it connects, hang up and report the codec agreed."});
555        let mut static_example = llm.clone();
556        static_example["event_handlers"] = json!([
557            {"event_pattern": "h323_client_ready", "handler": {"type": "static", "actions": [
558                {"type": PLACE_CALL, "destination": "1002"}
559            ]}},
560            {"event_pattern": "h323_call_connected", "handler": {"type": "static", "actions": [
561                {"type": HANGUP}
562            ]}},
563            {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
564        ]);
565        let mut scripted = static_example.clone();
566        scripted["event_handlers"][0] = json!({"event_pattern": "h323_client_ready", "handler": {"type": "script", "language": "python",
567            "code": "import json,sys\ne=json.load(sys.stdin)['event']\nreg=(e.get('registration') or {})\na=[{'type':'h323_place_call','destination':'1002'}] if reg.get('result')!='rejected' else [{'type':'h323_register','aliases':['1004']}]\nprint(json.dumps({'actions':a}))"}});
568        StartupExamples::new(llm, scripted, static_example)
569    }
570    fn group_name(&self) -> &'static str {
571        "VoIP & Media"
572    }
573}
574
575impl Client for H323ClientProtocol {
576    fn connect(
577        &self,
578        ctx: ConnectContext,
579    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<SocketAddr>> + Send>> {
580        Box::pin(super::connect(ctx))
581    }
582
583    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
584        parse_command(&v)?;
585        Ok(ClientActionResult::Custom {
586            name: v["type"].as_str().unwrap_or_default().to_string(),
587            data: v,
588        })
589    }
590}