Skip to main content

netget/server/h323/
actions.rs

1//! What the model decides as an H.323 gatekeeper and as a called endpoint.
2//!
3//! Gatekeeper (`H323-Gatekeeper`, RAS on UDP): who may use this gatekeeper (GRQ), who may
4//! register and with which aliases and for how long (RRQ), which calls are admitted and where
5//! they are routed (ARQ), and where an alias is (LRQ). Endpoint (`H323`, call signalling on
6//! TCP): whether an incoming call is answered, rejected, left ringing or forwarded.
7//!
8//! Rust owns everything under that: PER encoding and decoding of H.225.0 and H.245, Q.931
9//! framing, call references and identifiers, fastStart, tunnelled H.245, time-to-live,
10//! retransmissions and every bound.
11use super::call::{self, cause, RELEASE_REASONS};
12use super::gatekeeper::{
13    self, ADMISSION_REASONS, GATEKEEPER_REASONS, LOCATION_REASONS, REGISTRATION_REASONS,
14};
15use super::{endpoint, h225, h245};
16use crate::llm::actions::{
17    protocol_trait::{ActionResult, Protocol, Server},
18    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
19};
20use crate::protocol::log_template::LogTemplate;
21use crate::protocol::EventType;
22use crate::state::app_state::AppState;
23use anyhow::{anyhow, bail, Context, Result};
24use serde_json::{json, Value};
25use std::net::SocketAddr;
26use std::sync::LazyLock;
27
28/// The gatekeeper identifier when none is configured.
29pub const DEFAULT_GATEKEEPER_ID: &str = "NetGet";
30/// The most bandwidth (in units of 100 bit/s, as H.225.0 counts it) a call is granted unless
31/// configured otherwise: 10 Mbit/s.
32pub const DEFAULT_MAX_BANDWIDTH: u64 = 100_000;
33
34pub const CONFIRM_GATEKEEPER: &str = "h323_confirm_gatekeeper";
35pub const REJECT_GATEKEEPER: &str = "h323_reject_gatekeeper";
36pub const CONFIRM_REGISTRATION: &str = "h323_confirm_registration";
37pub const REJECT_REGISTRATION: &str = "h323_reject_registration";
38pub const CONFIRM_ADMISSION: &str = "h323_confirm_admission";
39pub const REJECT_ADMISSION: &str = "h323_reject_admission";
40pub const CONFIRM_LOCATION: &str = "h323_confirm_location";
41pub const REJECT_LOCATION: &str = "h323_reject_location";
42
43pub const CALL_PROCEEDING: &str = "h323_call_proceeding";
44pub const ALERTING: &str = "h323_alerting";
45pub const CONNECT: &str = "h323_connect";
46pub const RELEASE_COMPLETE: &str = "h323_release_complete";
47pub const FORWARD_CALL: &str = "h323_forward_call";
48
49pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
50    Parameter {
51        name: name.into(),
52        type_hint: type_hint.into(),
53        description: description.into(),
54        required,
55    }
56}
57
58fn action(
59    name: &str,
60    description: &str,
61    parameters: Vec<Parameter>,
62    example: Value,
63) -> ActionDefinition {
64    ActionDefinition {
65        name: name.into(),
66        description: description.into(),
67        parameters,
68        example,
69        log_template: Some(
70            LogTemplate::new()
71                .with_info(format!("H.323 {name}"))
72                .with_debug(format!("H.323 {name}: {{json(.)}}")),
73        ),
74    }
75}
76
77fn reason_of(action: &Value, allowed: &[&str], default: &str) -> Result<String> {
78    let r = action
79        .get("reason")
80        .and_then(|r| r.as_str())
81        .unwrap_or(default)
82        .to_string();
83    if !allowed.contains(&r.as_str()) {
84        bail!("reason {r:?} is not one of {allowed:?}");
85    }
86    Ok(r)
87}
88
89// ---------------------------------------------------------------------------------------------
90// Gatekeeper
91// ---------------------------------------------------------------------------------------------
92
93/// One gatekeeper decision.
94#[derive(Debug, Clone)]
95pub enum GkCommand {
96    ConfirmGatekeeper,
97    RejectGatekeeper(String),
98    ConfirmRegistration {
99        aliases: Option<Vec<Value>>,
100        ttl: Option<u64>,
101    },
102    RejectRegistration {
103        reason: String,
104        duplicates: Option<Vec<Value>>,
105    },
106    ConfirmAdmission {
107        destination: Option<String>,
108        bandwidth: Option<u64>,
109    },
110    RejectAdmission(String),
111    ConfirmLocation {
112        destination: Option<String>,
113    },
114    RejectLocation(String),
115}
116
117fn destination_of(a: &Value) -> Result<Option<String>> {
118    match a.get("destination") {
119        None | Some(Value::Null) => Ok(None),
120        Some(Value::String(s)) => {
121            if s.parse::<SocketAddr>().is_err() {
122                h225::alias(s)?;
123            }
124            Ok(Some(s.clone()))
125        }
126        Some(other) => bail!("destination {other} is not a string"),
127    }
128}
129
130pub fn parse_gk_command(a: &Value) -> Result<GkCommand> {
131    let kind = a
132        .get("type")
133        .and_then(|t| t.as_str())
134        .ok_or_else(|| anyhow!("missing 'type'"))?;
135    Ok(match kind {
136        CONFIRM_GATEKEEPER => GkCommand::ConfirmGatekeeper,
137        REJECT_GATEKEEPER => {
138            GkCommand::RejectGatekeeper(reason_of(a, GATEKEEPER_REASONS, "terminalExcluded")?)
139        }
140        CONFIRM_REGISTRATION => {
141            let aliases = match a.get("aliases") {
142                None | Some(Value::Null) => None,
143                Some(v) => Some(h225::aliases(v)?),
144            };
145            let ttl = match a.get("time_to_live") {
146                None | Some(Value::Null) => None,
147                Some(t) => Some(
148                    t.as_u64()
149                        .filter(|t| (1..=gatekeeper::MAX_TTL_SECS).contains(t))
150                        .ok_or_else(|| {
151                            anyhow!("time_to_live is 1 to {} seconds", gatekeeper::MAX_TTL_SECS)
152                        })?,
153                ),
154            };
155            GkCommand::ConfirmRegistration { aliases, ttl }
156        }
157        REJECT_REGISTRATION => {
158            let reason = reason_of(a, REGISTRATION_REASONS, "undefinedReason")?;
159            let duplicates = match a.get("aliases") {
160                Some(v) if reason == "duplicateAlias" => Some(h225::aliases(v)?),
161                _ => None,
162            };
163            GkCommand::RejectRegistration { reason, duplicates }
164        }
165        CONFIRM_ADMISSION => GkCommand::ConfirmAdmission {
166            destination: destination_of(a)?,
167            bandwidth: match a.get("bandwidth") {
168                None | Some(Value::Null) => None,
169                Some(b) => Some(
170                    b.as_u64()
171                        .filter(|b| *b <= u32::MAX as u64)
172                        .ok_or_else(|| anyhow!("bandwidth is a number of 100 bit/s units"))?,
173                ),
174            },
175        },
176        REJECT_ADMISSION => {
177            GkCommand::RejectAdmission(reason_of(a, ADMISSION_REASONS, "requestDenied")?)
178        }
179        CONFIRM_LOCATION => GkCommand::ConfirmLocation {
180            destination: destination_of(a)?,
181        },
182        REJECT_LOCATION => {
183            GkCommand::RejectLocation(reason_of(a, LOCATION_REASONS, "requestDenied")?)
184        }
185        other => bail!("{other} is not an H.323 gatekeeper action"),
186    })
187}
188
189fn confirm_gatekeeper_action() -> ActionDefinition {
190    action(
191        CONFIRM_GATEKEEPER,
192        "Answer a GRQ with GCF: this gatekeeper will serve the endpoint, which normally \
193         registers next.",
194        vec![],
195        json!({"type": CONFIRM_GATEKEEPER}),
196    )
197}
198
199fn reject_gatekeeper_action() -> ActionDefinition {
200    action(
201        REJECT_GATEKEEPER,
202        "Answer a GRQ with GRJ: this gatekeeper will not serve the endpoint.",
203        vec![p(
204            "reason",
205            "string",
206            &format!("One of {}", GATEKEEPER_REASONS.join(", ")),
207            false,
208        )],
209        json!({"type": REJECT_GATEKEEPER, "reason": "terminalExcluded"}),
210    )
211}
212
213fn confirm_registration_action() -> ActionDefinition {
214    action(
215        CONFIRM_REGISTRATION,
216        "Answer an RRQ with RCF: register the endpoint. NetGet assigns its endpoint identifier \
217         and remembers the registration (aliases → call signalling address) until its \
218         time-to-live runs out; ARQ and LRQ then find it by alias.",
219        vec![
220            p(
221                "aliases",
222                "array",
223                "The aliases it is registered under, as \"kind:value\" (h323-ID:alice, \
224                 dialledDigits:1001) or bare values; default the ones it asked for. An alias \
225                 another endpoint holds is refused.",
226                false,
227            ),
228            p(
229                "time_to_live",
230                "number",
231                "Seconds the registration lasts before the endpoint must refresh it \
232                 (default: what it asked for, else the configured default)",
233                false,
234            ),
235        ],
236        json!({"type": CONFIRM_REGISTRATION, "aliases": ["h323-ID:alice", "dialledDigits:1001"], "time_to_live": 300}),
237    )
238}
239
240fn reject_registration_action() -> ActionDefinition {
241    action(
242        REJECT_REGISTRATION,
243        "Answer an RRQ with RRJ: refuse to register the endpoint.",
244        vec![
245            p(
246                "reason",
247                "string",
248                &format!("One of {}", REGISTRATION_REASONS.join(", ")),
249                false,
250            ),
251            p(
252                "aliases",
253                "array",
254                "With reason duplicateAlias: the aliases already taken",
255                false,
256            ),
257        ],
258        json!({"type": REJECT_REGISTRATION, "reason": "securityDenial"}),
259    )
260}
261
262fn confirm_admission_action() -> ActionDefinition {
263    action(
264        CONFIRM_ADMISSION,
265        "Answer an ARQ with ACF: admit the call and tell the caller where to send its Setup. \
266         The destination is an alias of a registered endpoint (its registered call signalling \
267         address is used) or an explicit ip:port. Default: for answer_call the endpoint \
268         itself, otherwise the first registered destination alias, else the address the ARQ \
269         named.",
270        vec![
271            p(
272                "destination",
273                "string",
274                "A registered alias (h323-ID:bob, 1002) or ip:port",
275                false,
276            ),
277            p(
278                "bandwidth",
279                "number",
280                "Bandwidth granted in units of 100 bit/s (default: what was asked, capped)",
281                false,
282            ),
283        ],
284        json!({"type": CONFIRM_ADMISSION, "destination": "h323-ID:bob"}),
285    )
286}
287
288fn reject_admission_action() -> ActionDefinition {
289    action(
290        REJECT_ADMISSION,
291        "Answer an ARQ with ARJ: the call may not proceed.",
292        vec![p(
293            "reason",
294            "string",
295            &format!("One of {}", ADMISSION_REASONS.join(", ")),
296            false,
297        )],
298        json!({"type": REJECT_ADMISSION, "reason": "calledPartyNotRegistered"}),
299    )
300}
301
302fn confirm_location_action() -> ActionDefinition {
303    action(
304        CONFIRM_LOCATION,
305        "Answer an LRQ with LCF: say where the alias can be called. Destination as for \
306         h323_confirm_admission; default the first registered destination alias.",
307        vec![p(
308            "destination",
309            "string",
310            "A registered alias or ip:port",
311            false,
312        )],
313        json!({"type": CONFIRM_LOCATION, "destination": "h323-ID:bob"}),
314    )
315}
316
317fn reject_location_action() -> ActionDefinition {
318    action(
319        REJECT_LOCATION,
320        "Answer an LRQ with LRJ: this gatekeeper cannot locate the alias.",
321        vec![p(
322            "reason",
323            "string",
324            &format!("One of {}", LOCATION_REASONS.join(", ")),
325            false,
326        )],
327        json!({"type": REJECT_LOCATION, "reason": "notRegistered"}),
328    )
329}
330
331pub fn gatekeeper_actions() -> Vec<ActionDefinition> {
332    vec![
333        confirm_gatekeeper_action(),
334        reject_gatekeeper_action(),
335        confirm_registration_action(),
336        reject_registration_action(),
337        confirm_admission_action(),
338        reject_admission_action(),
339        confirm_location_action(),
340        reject_location_action(),
341    ]
342}
343
344pub static GRQ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
345    EventType::new(
346        "h323_gatekeeper_request",
347        "An endpoint is looking for a gatekeeper (GRQ). Accept with h323_confirm_gatekeeper or \
348         refuse with h323_reject_gatekeeper; anything else is answered GRJ",
349        json!({"type": CONFIRM_GATEKEEPER}),
350    )
351    .with_parameters(vec![
352        p("from", "string", "Where the GRQ came from", true),
353        p(
354            "ras_address",
355            "string",
356            "The RAS address it asks to be answered on",
357            false,
358        ),
359        p(
360            "endpoint",
361            "object",
362            "What it is (terminal, gateway, mcu) and its product and version",
363            true,
364        ),
365        p("aliases", "array", "Aliases it named", true),
366        p(
367            "requested_gatekeeper",
368            "string",
369            "The gatekeeper identifier it asked for, if any",
370            false,
371        ),
372        p(
373            "this_gatekeeper",
374            "string",
375            "This gatekeeper's identifier",
376            true,
377        ),
378    ])
379    .with_actions(vec![
380        confirm_gatekeeper_action(),
381        reject_gatekeeper_action(),
382    ])
383    .with_alternative_example(json!({"type": REJECT_GATEKEEPER, "reason": "terminalExcluded"}))
384    .with_log_template(
385        LogTemplate::new()
386            .with_info("H.323 GRQ from {from}")
387            .with_debug("H.323 GRQ: {json(.)}"),
388    )
389});
390
391pub static RRQ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
392    EventType::new(
393        "h323_registration_request",
394        "An endpoint asks to register (full RRQ). Register it with h323_confirm_registration \
395         (choosing its aliases and time-to-live) or refuse with h323_reject_registration; \
396         anything else is answered RRJ",
397        json!({"type": CONFIRM_REGISTRATION}),
398    )
399    .with_parameters(vec![
400        p("from", "string", "Where the RRQ came from", true),
401        p(
402            "aliases",
403            "array",
404            "The aliases it asks for, as kind:value",
405            true,
406        ),
407        p(
408            "call_signal_addresses",
409            "array",
410            "Where it takes calls (ip:port)",
411            true,
412        ),
413        p("ras_addresses", "array", "Its RAS addresses", true),
414        p(
415            "endpoint",
416            "object",
417            "What it is (terminal, gateway, mcu) and its product and version",
418            true,
419        ),
420        p(
421            "vendor",
422            "object",
423            "endpointVendor: product and version",
424            true,
425        ),
426        p(
427            "requested_time_to_live",
428            "number",
429            "The time-to-live it asked for, if any",
430            false,
431        ),
432        p(
433            "already_registered_as",
434            "string",
435            "Its endpoint identifier if it is re-registering",
436            false,
437        ),
438        p(
439            "aliases_held_by_other_endpoints",
440            "array",
441            "Requested aliases another endpoint already holds",
442            true,
443        ),
444        p(
445            "registered_endpoints",
446            "number",
447            "How many endpoints are registered now",
448            true,
449        ),
450    ])
451    .with_actions(vec![
452        confirm_registration_action(),
453        reject_registration_action(),
454    ])
455    .with_alternative_example(json!({"type": REJECT_REGISTRATION, "reason": "securityDenial"}))
456    .with_log_template(
457        LogTemplate::new()
458            .with_info("H.323 RRQ from {from} for {aliases}")
459            .with_debug("H.323 RRQ: {json(.)}"),
460    )
461});
462
463pub static ARQ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
464    EventType::new(
465        "h323_admission_request",
466        "A registered endpoint asks to place (or, with answer_call, to answer) a call (ARQ). \
467         Admit it with h323_confirm_admission, choosing the destination, or refuse with \
468         h323_reject_admission; anything else is answered ARJ",
469        json!({"type": CONFIRM_ADMISSION}),
470    )
471    .with_parameters(vec![
472        p("from", "string", "Where the ARQ came from", true),
473        p(
474            "endpoint_id",
475            "string",
476            "The caller's endpoint identifier",
477            true,
478        ),
479        p(
480            "caller_aliases",
481            "array",
482            "The aliases it is registered under",
483            true,
484        ),
485        p("source_info", "array", "srcInfo aliases in the ARQ", true),
486        p("destination_aliases", "array", "Who it wants to call", true),
487        p(
488            "destination_address",
489            "string",
490            "An address it named directly, if any",
491            false,
492        ),
493        p(
494            "registered_destinations",
495            "array",
496            "Destination aliases that are registered here, with their endpoint and address",
497            true,
498        ),
499        p(
500            "answer_call",
501            "boolean",
502            "True when the endpoint asks to answer an incoming call",
503            true,
504        ),
505        p(
506            "bandwidth",
507            "number",
508            "Bandwidth asked for, in 100 bit/s units",
509            true,
510        ),
511        p("call_reference", "number", "The call reference value", true),
512        p("call_id", "string", "The call identifier (GUID, hex)", true),
513        p(
514            "conference_id",
515            "string",
516            "The conference identifier (hex)",
517            true,
518        ),
519    ])
520    .with_actions(vec![confirm_admission_action(), reject_admission_action()])
521    .with_alternative_example(
522        json!({"type": REJECT_ADMISSION, "reason": "calledPartyNotRegistered"}),
523    )
524    .with_log_template(
525        LogTemplate::new()
526            .with_info("H.323 ARQ from {endpoint_id} to {destination_aliases}")
527            .with_debug("H.323 ARQ: {json(.)}"),
528    )
529});
530
531pub static LRQ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
532    EventType::new(
533        "h323_location_request",
534        "Another gatekeeper or an endpoint asks where an alias can be called (LRQ). Answer with \
535         h323_confirm_location or h323_reject_location; anything else is answered LRJ",
536        json!({"type": CONFIRM_LOCATION}),
537    )
538    .with_parameters(vec![
539        p("from", "string", "Where the LRQ came from", true),
540        p(
541            "destination_aliases",
542            "array",
543            "The aliases looked for",
544            true,
545        ),
546        p("source_info", "array", "Who is asking, as aliases", true),
547        p(
548            "reply_address",
549            "string",
550            "Where the answer is wanted",
551            false,
552        ),
553        p(
554            "registered_destinations",
555            "array",
556            "Which of the aliases are registered here",
557            true,
558        ),
559    ])
560    .with_actions(vec![confirm_location_action(), reject_location_action()])
561    .with_alternative_example(json!({"type": REJECT_LOCATION, "reason": "notRegistered"}))
562    .with_log_template(
563        LogTemplate::new().with_info("H.323 LRQ from {from} for {destination_aliases}"),
564    )
565});
566
567fn gatekeeper_event_types() -> Vec<EventType> {
568    vec![
569        GRQ_EVENT.clone(),
570        RRQ_EVENT.clone(),
571        ARQ_EVENT.clone(),
572        LRQ_EVENT.clone(),
573    ]
574}
575
576#[derive(Clone, Default)]
577pub struct H323GatekeeperProtocol;
578
579impl H323GatekeeperProtocol {
580    pub fn new() -> Self {
581        Self
582    }
583}
584
585impl Protocol for H323GatekeeperProtocol {
586    fn protocol_name(&self) -> &'static str {
587        "H323-Gatekeeper"
588    }
589    fn stack_name(&self) -> &'static str {
590        "ETH>IP>UDP>H.225.0 RAS"
591    }
592    fn description(&self) -> &'static str {
593        "H.323 gatekeeper (H.225.0 RAS) whose discovery, registration, admission and location answers the LLM decides"
594    }
595    fn example_prompt(&self) -> &'static str {
596        "Start an H.323 gatekeeper on port 1719; register anyone whose alias starts with 10, and admit calls only between registered endpoints"
597    }
598    fn keywords(&self) -> Vec<&'static str> {
599        vec![
600            "h323 gatekeeper",
601            "h.323 gatekeeper",
602            "gatekeeper",
603            "h.225 ras",
604            "h225 ras",
605            "ras",
606            "rrq",
607            "arq",
608        ]
609    }
610    fn group_name(&self) -> &'static str {
611        "VoIP & Media"
612    }
613
614    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
615        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
616        ProtocolMetadataV2::builder()
617            .state(DevelopmentState::Experimental)
618            .well_known_udp_port(1719)
619            .connectionless()
620            .answers_on_failure()
621            .max_inbound_bytes(gatekeeper::MAX_RAS_DATAGRAM)
622            .implementation(
623                "Hand-written ALIGNED PER (X.691) interpreted over schema tables generated from \
624                 the ITU H.225.0 (12/2009), H.235.0 and H.245 ASN.1 modules \
625                 (scripts/h323_asn1_schema.py): every RAS message decodes, and NetGet answers \
626                 GRQ, RRQ (full and keep-alive), URQ, ARQ, DRQ, LRQ, BRQ, IRR (IACK), RAI and \
627                 retransmissions (cached answer, or RIP while the model decides). \
628                 Registrations and admitted calls are bounded runtime state with time-to-live",
629            )
630            .llm_control(
631                "GCF/GRJ, RCF (aliases, time-to-live)/RRJ, ACF (destination: a registered alias \
632                 or ip:port, bandwidth)/ARJ and LCF/LRJ",
633            )
634            .e2e_testing(
635                "tests/server/h323: Asterisk's chan_ooh323 (ooh323c) and H323Plus's simph323 \
636                 each register with NetGet's gatekeeper and are admitted to call NetGet's \
637                 endpoint; a refused registration shows in the peer's own log; tshark's h225 \
638                 dissector reads every RAS message both ways from a live capture",
639            )
640            .notes(
641                "Experimental: two independent H.323 implementations (ooh323c and H323Plus) \
642                 complete registration and admission against it in tests that fail rather than \
643                 skip, and its PER decoder has a fuzz target (fuzz/fuzz_targets/h323_per.rs, \
644                 with a depth bomb in its corpus, run once for 500 000 inputs without a \
645                 sanitizer), but nobody has reviewed it against H.225.0 clause by clause and its \
646                 pair job had not run in CI when it landed. \
647                 Direct call model only (ACF names the destination; the gatekeeper does not route \
648                 signalling). No H.235 security: tokens are decoded past by length and never \
649                 checked. No alternate gatekeepers, no inter-gatekeeper LRQ forwarding.",
650            )
651            .build()
652    }
653
654    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
655        vec![
656            ParameterDefinition {
657                name: "gatekeeper_id".into(),
658                type_hint: "string".into(),
659                description: "The gatekeeper identifier sent in GCF, RCF and rejects".into(),
660                required: false,
661                example: json!("NetGetGK"),
662                default: Some(json!(DEFAULT_GATEKEEPER_ID)),
663            },
664            ParameterDefinition {
665                name: "time_to_live".into(),
666                type_hint: "number".into(),
667                description: "Registration time-to-live in seconds when neither the endpoint nor \
668                              the model chooses one"
669                    .into(),
670                required: false,
671                example: json!(600),
672                default: Some(json!(gatekeeper::DEFAULT_TTL_SECS)),
673            },
674            ParameterDefinition {
675                name: "max_bandwidth".into(),
676                type_hint: "number".into(),
677                description: "The most bandwidth one call is granted, in units of 100 bit/s".into(),
678                required: false,
679                example: json!(1280),
680                default: Some(json!(DEFAULT_MAX_BANDWIDTH)),
681            },
682        ]
683    }
684
685    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
686        Vec::new()
687    }
688
689    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
690        gatekeeper_actions()
691    }
692
693    fn get_event_types(&self) -> Vec<EventType> {
694        gatekeeper_event_types()
695    }
696
697    fn get_startup_examples(&self) -> StartupExamples {
698        StartupExamples::new(
699            json!({
700                "type": "open_server",
701                "port": 1719,
702                "base_stack": "H323-Gatekeeper",
703                "startup_params": {"gatekeeper_id": "NetGetGK"},
704                "instruction": "Accept every GRQ. Register endpoints whose aliases are digits starting with 10; refuse others with securityDenial. Admit calls to registered aliases only."
705            }),
706            json!({
707                "type": "open_server",
708                "port": 1719,
709                "base_stack": "H323-Gatekeeper",
710                "event_handlers": [{
711                    "event_pattern": "*",
712                    "handler": {
713                        "type": "script",
714                        "language": "python",
715                        "code": "import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\na=[]\nif t=='h323_gatekeeper_request': a=[{'type':'h323_confirm_gatekeeper'}]\nelif t=='h323_registration_request': a=[{'type':'h323_confirm_registration'}]\nelif t=='h323_admission_request':\n    a=[{'type':'h323_confirm_admission'}] if e.get('registered_destinations') or e.get('answer_call') else [{'type':'h323_reject_admission','reason':'calledPartyNotRegistered'}]\nelif t=='h323_location_request': a=[{'type':'h323_reject_location','reason':'notRegistered'}]\nprint(json.dumps({'actions':a}))"
716                    }
717                }]
718            }),
719            json!({
720                "type": "open_server",
721                "port": 1719,
722                "base_stack": "H323-Gatekeeper",
723                "event_handlers": [
724                    {"event_pattern": "h323_gatekeeper_request", "handler": {"type": "static", "actions": [{"type": CONFIRM_GATEKEEPER}]}},
725                    {"event_pattern": "h323_registration_request", "handler": {"type": "static", "actions": [{"type": CONFIRM_REGISTRATION, "time_to_live": 300}]}},
726                    {"event_pattern": "h323_admission_request", "handler": {"type": "static", "actions": [{"type": CONFIRM_ADMISSION}]}},
727                    {"event_pattern": "*", "handler": {"type": "static", "actions": [{"type": REJECT_LOCATION, "reason": "notRegistered"}]}}
728                ]
729            }),
730        )
731    }
732}
733
734impl Server for H323GatekeeperProtocol {
735    fn spawn(
736        &self,
737        ctx: crate::protocol::SpawnContext,
738    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<SocketAddr>> + Send>> {
739        Box::pin(async move {
740            let params = ctx.startup_params.as_ref();
741            let gatekeeper_id = params
742                .map(|p| p.get_optional_string("gatekeeper_id"))
743                .transpose()?
744                .flatten()
745                .unwrap_or_else(|| DEFAULT_GATEKEEPER_ID.to_string());
746            gatekeeper::validate_gatekeeper_id(&gatekeeper_id)?;
747            let default_ttl = params
748                .map(|p| p.get_optional_u64("time_to_live"))
749                .transpose()?
750                .flatten()
751                .unwrap_or(gatekeeper::DEFAULT_TTL_SECS);
752            anyhow::ensure!(
753                (1..=gatekeeper::MAX_TTL_SECS).contains(&default_ttl),
754                "time_to_live is 1 to {} seconds",
755                gatekeeper::MAX_TTL_SECS
756            );
757            let max_bandwidth = params
758                .map(|p| p.get_optional_u64("max_bandwidth"))
759                .transpose()?
760                .flatten()
761                .unwrap_or(DEFAULT_MAX_BANDWIDTH)
762                .min(u32::MAX as u64);
763            gatekeeper::spawn(
764                ctx.legacy_listen_addr(),
765                ctx.llm_client,
766                ctx.state,
767                ctx.status_tx,
768                ctx.server_id,
769                gatekeeper::GatekeeperConfig {
770                    gatekeeper_id,
771                    default_ttl,
772                    max_bandwidth,
773                },
774            )
775            .await
776        })
777    }
778
779    fn execute_action(&self, action: Value) -> Result<ActionResult> {
780        parse_gk_command(&action)?;
781        Ok(ActionResult::Custom {
782            name: action["type"].as_str().unwrap_or_default().to_string(),
783            data: action,
784        })
785    }
786}
787
788// ---------------------------------------------------------------------------------------------
789// Endpoint (called side)
790// ---------------------------------------------------------------------------------------------
791
792/// One call-control decision on the called side.
793#[derive(Debug, Clone)]
794pub enum CallCommand {
795    Proceeding,
796    Alerting,
797    Connect {
798        codecs: Vec<h245::Codec>,
799        display: Option<String>,
800    },
801    Release {
802        cause: u8,
803        reason: Option<String>,
804    },
805    Forward {
806        address: Option<SocketAddr>,
807        aliases: Vec<Value>,
808    },
809}
810
811impl CallCommand {
812    pub fn name(&self) -> &'static str {
813        match self {
814            CallCommand::Proceeding => "CallProceeding",
815            CallCommand::Alerting => "Alerting",
816            CallCommand::Connect { .. } => "Connect",
817            CallCommand::Release { .. } => "ReleaseComplete",
818            CallCommand::Forward { .. } => "Facility (callForwarded)",
819        }
820    }
821}
822
823/// A display string the model wrote: refused, not rewritten, if it holds a control character.
824pub fn display_of(a: &Value) -> Result<Option<String>> {
825    match a.get("display") {
826        None | Some(Value::Null) => Ok(None),
827        Some(Value::String(s)) => {
828            if s.is_empty() || s.chars().count() > 80 || !s.is_ascii() {
829                bail!("display is 1 to 80 ASCII characters");
830            }
831            if crate::utils::sanitize::has_controls(s) {
832                bail!("display contains a control character");
833            }
834            Ok(Some(s.clone()))
835        }
836        Some(other) => bail!("display {other} is not a string"),
837    }
838}
839
840/// A Q.850 cause and optional H.225.0 reason from a release action.
841pub fn release_of(a: &Value) -> Result<(u8, Option<String>)> {
842    let c = match a.get("cause") {
843        None | Some(Value::Null) => cause::NORMAL_CLEARING,
844        Some(v) => v
845            .as_u64()
846            .filter(|c| (1..=127).contains(c))
847            .map(|c| c as u8)
848            .ok_or_else(|| anyhow!("cause is a Q.850 cause value, 1 to 127"))?,
849    };
850    let reason = match a.get("reason") {
851        None | Some(Value::Null) => None,
852        Some(Value::String(r)) => {
853            if !RELEASE_REASONS.contains(&r.as_str()) {
854                bail!("reason {r:?} is not one of {RELEASE_REASONS:?}");
855            }
856            Some(r.clone())
857        }
858        Some(other) => bail!("reason {other} is not a string"),
859    };
860    Ok((c, reason))
861}
862
863pub fn parse_call_command(a: &Value) -> Result<CallCommand> {
864    let kind = a
865        .get("type")
866        .and_then(|t| t.as_str())
867        .ok_or_else(|| anyhow!("missing 'type'"))?;
868    Ok(match kind {
869        CALL_PROCEEDING => CallCommand::Proceeding,
870        ALERTING => CallCommand::Alerting,
871        CONNECT => CallCommand::Connect {
872            codecs: match a.get("codec") {
873                None | Some(Value::Null) => Vec::new(),
874                v => call::codecs_from(v)?,
875            },
876            display: display_of(a)?,
877        },
878        RELEASE_COMPLETE => {
879            let (cause, reason) = release_of(a)?;
880            CallCommand::Release { cause, reason }
881        }
882        FORWARD_CALL => {
883            let to = a
884                .get("destination")
885                .and_then(|d| d.as_str())
886                .context("destination (an alias or ip:port) is required")?;
887            match to.parse::<SocketAddr>() {
888                Ok(addr) => CallCommand::Forward {
889                    address: Some(addr),
890                    aliases: Vec::new(),
891                },
892                Err(_) => CallCommand::Forward {
893                    address: None,
894                    aliases: vec![h225::alias(to)?],
895                },
896            }
897        }
898        other => bail!("{other} is not an H.323 call action"),
899    })
900}
901
902fn proceeding_action() -> ActionDefinition {
903    action(
904        CALL_PROCEEDING,
905        "Send CallProceeding: the call is being handled (stops the caller's Setup timer).",
906        vec![],
907        json!({"type": CALL_PROCEEDING}),
908    )
909}
910
911fn alerting_action() -> ActionDefinition {
912    action(
913        ALERTING,
914        "Send Alerting: the called party is ringing. A call left ringing is released after \
915         two minutes unless h323_connect follows (later, from the dashboard or MCP).",
916        vec![],
917        json!({"type": ALERTING}),
918    )
919}
920
921fn connect_action() -> ActionDefinition {
922    action(
923        CONNECT,
924        "Answer the call (Connect). NetGet accepts the caller's fastStart offer in the first \
925         codec both sides have and runs tunnelled H.245 (capability exchange, master-slave \
926         determination, logical channels) itself.",
927        vec![
928            p(
929                "codec",
930                "string",
931                "Preferred codec, g711u or g711a, or a list in order (default: as configured)",
932                false,
933            ),
934            p(
935                "display",
936                "string",
937                "Display name sent with the Connect (ASCII, up to 80 characters)",
938                false,
939            ),
940        ],
941        json!({"type": CONNECT, "codec": "g711u"}),
942    )
943}
944
945pub fn release_action() -> ActionDefinition {
946    action(
947        RELEASE_COMPLETE,
948        "End or refuse the call with Release Complete.",
949        vec![
950            p(
951                "cause",
952                "number",
953                "Q.850 cause: 16 normal clearing (default), 17 user busy, 19 no answer, 21 call \
954                 rejected, 1 unallocated number, 31 normal unspecified",
955                false,
956            ),
957            p(
958                "reason",
959                "string",
960                &format!("H.225.0 reason, one of {}", RELEASE_REASONS.join(", ")),
961                false,
962            ),
963        ],
964        json!({"type": RELEASE_COMPLETE, "cause": 17, "reason": "inConf"}),
965    )
966}
967
968fn forward_action() -> ActionDefinition {
969    action(
970        FORWARD_CALL,
971        "Deflect the call elsewhere: Facility (callForwarded) naming the new destination, then \
972         Release Complete. The caller places a new call there.",
973        vec![p(
974            "destination",
975            "string",
976            "An alias (h323-ID:bob, 1002) or ip:port to call instead",
977            true,
978        )],
979        json!({"type": FORWARD_CALL, "destination": "h323-ID:voicemail"}),
980    )
981}
982
983pub fn endpoint_actions() -> Vec<ActionDefinition> {
984    vec![
985        proceeding_action(),
986        alerting_action(),
987        connect_action(),
988        release_action(),
989        forward_action(),
990    ]
991}
992
993pub static SETUP_EVENT: LazyLock<EventType> = LazyLock::new(|| {
994    EventType::new(
995        "h323_setup",
996        "A caller sent Setup. Answer with h323_connect (h323_call_proceeding and h323_alerting \
997         may come first), refuse with h323_release_complete, or deflect with h323_forward_call. \
998         An answer with none of these releases the call",
999        json!({"type": CONNECT}),
1000    )
1001    .with_parameters(vec![
1002        p("remote", "string", "The caller's address", true),
1003        p("crv", "number", "The Q.931 call reference value", true),
1004        p(
1005            "call_id",
1006            "string",
1007            "The H.225.0 call identifier (GUID, hex)",
1008            true,
1009        ),
1010        p(
1011            "conference_id",
1012            "string",
1013            "The conference identifier (hex)",
1014            true,
1015        ),
1016        p(
1017            "calling_number",
1018            "string",
1019            "Calling party number IE, if present",
1020            false,
1021        ),
1022        p(
1023            "called_number",
1024            "string",
1025            "Called party number IE, if present",
1026            false,
1027        ),
1028        p("display", "string", "Display IE, if present", false),
1029        p(
1030            "source_aliases",
1031            "array",
1032            "The caller's aliases (kind:value)",
1033            true,
1034        ),
1035        p(
1036            "destination_aliases",
1037            "array",
1038            "Who it is calling (kind:value)",
1039            true,
1040        ),
1041        p(
1042            "source_endpoint",
1043            "object",
1044            "What the caller is (terminal, gateway, ...) and its product and version",
1045            true,
1046        ),
1047        p(
1048            "source_call_signal_address",
1049            "string",
1050            "The caller's signalling address, if given",
1051            false,
1052        ),
1053        p(
1054            "fast_start",
1055            "array",
1056            "fastStart proposals: direction and codec of each",
1057            true,
1058        ),
1059        p(
1060            "h245_tunnelling",
1061            "boolean",
1062            "Whether the caller tunnels H.245",
1063            true,
1064        ),
1065        p(
1066            "h245_address",
1067            "string",
1068            "A separate H.245 address the caller offered, if any",
1069            false,
1070        ),
1071        p(
1072            "endpoint_identifier",
1073            "string",
1074            "The caller's endpoint identifier at its gatekeeper, if given",
1075            false,
1076        ),
1077    ])
1078    .with_actions(endpoint_actions())
1079    .with_alternative_example(
1080        json!({"type": RELEASE_COMPLETE, "cause": 21, "reason": "destinationRejection"}),
1081    )
1082    .with_log_template(
1083        LogTemplate::new()
1084            .with_info("H.323 Setup from {remote}: {calling_number} → {called_number}")
1085            .with_debug("H.323 Setup: {json(.)}"),
1086    )
1087});
1088
1089pub static USER_INPUT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1090    EventType::new(
1091        "h323_user_input",
1092        "The caller sent user input (DTMF) over H.245 during the call. Hang up with \
1093         h323_release_complete, or do nothing",
1094        json!({"type": RELEASE_COMPLETE}),
1095    )
1096    .with_parameters(vec![
1097        p("input", "string", "The digit or text", true),
1098        p("call_id", "string", "The call identifier", true),
1099        p("state", "string", "The call's state", true),
1100    ])
1101    .with_actions(vec![release_action()])
1102    .with_log_template(LogTemplate::new().with_info("H.323 user input {input}"))
1103});
1104
1105#[derive(Clone, Default)]
1106pub struct H323EndpointProtocol;
1107
1108impl H323EndpointProtocol {
1109    pub fn new() -> Self {
1110        Self
1111    }
1112}
1113
1114pub fn codecs_parameter() -> ParameterDefinition {
1115    ParameterDefinition {
1116        name: "codecs".into(),
1117        type_hint: "array".into(),
1118        description: "Audio codecs NetGet negotiates, in order of preference: g711u, g711a".into(),
1119        required: false,
1120        example: json!(["g711a", "g711u"]),
1121        default: Some(json!(h245::ALL_CODECS
1122            .iter()
1123            .map(|c| c.name())
1124            .collect::<Vec<_>>())),
1125    }
1126}
1127
1128pub fn codecs_from_params(
1129    params: Option<&crate::protocol::StartupParams>,
1130) -> Result<Vec<h245::Codec>> {
1131    let v = params
1132        .map(|p| p.get_optional_array("codecs"))
1133        .transpose()?
1134        .flatten();
1135    call::codecs_from(v.map(|a| Value::Array(a.clone())).as_ref())
1136}
1137
1138impl Protocol for H323EndpointProtocol {
1139    fn protocol_name(&self) -> &'static str {
1140        "H323"
1141    }
1142    fn stack_name(&self) -> &'static str {
1143        "ETH>IP>TCP>TPKT>Q.931>H.225.0"
1144    }
1145    fn description(&self) -> &'static str {
1146        "H.323 endpoint (H.225.0 call signalling over Q.931) that answers, rejects or forwards calls as the LLM decides, with fastStart and tunnelled H.245 for G.711"
1147    }
1148    fn example_prompt(&self) -> &'static str {
1149        "Start an H.323 endpoint on port 1720; answer calls to 1001 and reject everything else as busy"
1150    }
1151    fn keywords(&self) -> Vec<&'static str> {
1152        vec![
1153            "h323",
1154            "h.323",
1155            "h.225",
1156            "h225",
1157            "q.931",
1158            "q931",
1159            "h.245",
1160            "video conferencing",
1161            "netmeeting",
1162        ]
1163    }
1164    fn group_name(&self) -> &'static str {
1165        "VoIP & Media"
1166    }
1167
1168    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
1169        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
1170        ProtocolMetadataV2::builder()
1171            .state(DevelopmentState::Experimental)
1172            .well_known_port(1720)
1173            .answers_on_failure()
1174            .max_inbound_bytes(super::q931::MAX_TPKT)
1175            .implementation(
1176                "TPKT and the H.225.0 profile of Q.931 by hand, H.225.0 UUIEs and H.245 in \
1177                 hand-written ALIGNED PER over schema tables generated from the ITU ASN.1 \
1178                 (scripts/h323_asn1_schema.py). The called side of one call per connection: \
1179                 Setup → CallProceeding/Alerting/Connect or Release Complete or a Facility \
1180                 forward; fastStart answered in G.711; tunnelled H.245 (TerminalCapabilitySet, \
1181                 MasterSlaveDetermination, OpenLogicalChannel, CloseLogicalChannel, \
1182                 RoundTripDelay, EndSession, user input); RTP/RTCP sockets bound and counted, \
1183                 no media sent",
1184            )
1185            .llm_control(
1186                "Whether each call is answered (and in which codec), left ringing, rejected with \
1187                 which Q.850 cause and H.225.0 reason, or forwarded to an alias or address; \
1188                 hanging up on user input",
1189            )
1190            .e2e_testing(
1191                "tests/server/h323: Asterisk's chan_ooh323 (ooh323c) and H323Plus's simph323 \
1192                 call NetGet's endpoint and see the call connected with fastStart media, or \
1193                 released with the model's cause; tshark's q931, h225 and h245 dissectors read \
1194                 every message both ways from a live capture",
1195            )
1196            .notes(
1197                "Experimental: two independent H.323 stacks (ooh323c and H323Plus) complete \
1198                 calls with it in tests that fail rather than skip and the PER decoder has a fuzz \
1199                 target (fuzz/fuzz_targets/h323_per.rs), but nobody has reviewed the H.245 \
1200                 subset against the specification and its pair job had not run in CI when it \
1201                 landed. No separate H.245 channel (tunnelling or fastStart only; a \
1202                 caller that offers neither still connects at the Q.931 level, with no media \
1203                 negotiated). G.711 only. No media is sent and none is decoded. No H.235 \
1204                 security, no H.450 supplementary services, no overlap sending.",
1205            )
1206            .build()
1207    }
1208
1209    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
1210        vec![codecs_parameter()]
1211    }
1212
1213    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
1214        Vec::new()
1215    }
1216
1217    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
1218        endpoint_actions()
1219    }
1220
1221    fn get_event_types(&self) -> Vec<EventType> {
1222        vec![SETUP_EVENT.clone(), USER_INPUT_EVENT.clone()]
1223    }
1224
1225    fn get_startup_examples(&self) -> StartupExamples {
1226        StartupExamples::new(
1227            json!({
1228                "type": "open_server",
1229                "port": 1720,
1230                "base_stack": "H323",
1231                "instruction": "Answer calls whose called number or destination alias is 1001. Reject everything else with cause 17 (busy)."
1232            }),
1233            json!({
1234                "type": "open_server",
1235                "port": 1720,
1236                "base_stack": "H323",
1237                "event_handlers": [{
1238                    "event_pattern": "h323_setup",
1239                    "handler": {
1240                        "type": "script",
1241                        "language": "python",
1242                        "code": "import json,sys\ne=json.load(sys.stdin)['event']\nwanted='dialledDigits:1001' in e.get('destination_aliases',[]) or e.get('called_number')=='1001'\na=[{'type':'h323_alerting'},{'type':'h323_connect'}] if wanted else [{'type':'h323_release_complete','cause':17,'reason':'inConf'}]\nprint(json.dumps({'actions':a}))"
1243                    }
1244                }, {"event_pattern": "*", "handler": {"type": "static", "actions": []}}]
1245            }),
1246            json!({
1247                "type": "open_server",
1248                "port": 1720,
1249                "base_stack": "H323",
1250                "event_handlers": [
1251                    {"event_pattern": "h323_setup", "handler": {"type": "static", "actions": [
1252                        {"type": CALL_PROCEEDING}, {"type": ALERTING}, {"type": CONNECT, "codec": "g711u"}
1253                    ]}},
1254                    {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
1255                ]
1256            }),
1257        )
1258    }
1259}
1260
1261impl Server for H323EndpointProtocol {
1262    fn spawn(
1263        &self,
1264        ctx: crate::protocol::SpawnContext,
1265    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<SocketAddr>> + Send>> {
1266        Box::pin(async move {
1267            let codecs = codecs_from_params(ctx.startup_params.as_ref())?;
1268            endpoint::spawn(
1269                ctx.legacy_listen_addr(),
1270                ctx.llm_client,
1271                ctx.state,
1272                ctx.status_tx,
1273                ctx.server_id,
1274                endpoint::EndpointConfig { codecs },
1275            )
1276            .await
1277        })
1278    }
1279
1280    fn execute_action(&self, action: Value) -> Result<ActionResult> {
1281        parse_call_command(&action)?;
1282        Ok(ActionResult::Custom {
1283            name: action["type"].as_str().unwrap_or_default().to_string(),
1284            data: action,
1285        })
1286    }
1287}