LCOV - code coverage report
Current view: top level - antares-notifier/src - mqtt.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 72.0 % 508 366
Test Date: 2026-09-21 10:31:06 Functions: 27.5 % 171 47

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! MQTT notification binding — CIM 009 clause 7 (feature `mqtt`).
       3              : //!
       4              : //! 7.2: a subscription whose `notification.endpoint.uri` uses the mqtt(s)
       5              : //! scheme gets its notifications as MQTT publishes. The message is a JSON
       6              : //! object `{"metadata": {...}, "body": <Notification per 5.3.1>}`; protocol
       7              : //! parameters ride in `notifier_info` (Table 7.2-1), receiver metadata in
       8              : //! `receiver_info` (Table 7.2-2).
       9              : 
      10              : use antares_jsonld::loader::EgressPolicy;
      11              : use antares_model::NgsiError;
      12              : use serde_json::{Map, Value};
      13              : use std::collections::HashMap;
      14              : use std::sync::Mutex;
      15              : use std::time::{Duration, Instant};
      16              : 
      17           24 : fn bad(m: String) -> NgsiError {
      18           24 :     NgsiError::BadRequestData(m)
      19           24 : }
      20              : 
      21              : use crate::redact_userinfo as redacted;
      22              : 
      23              : /// Parsed `mqtt[s]://[user][:pass]@host[:port]/topic[/subtopic]*` (7.2).
      24              : #[derive(Debug, Clone, PartialEq)]
      25              : pub struct MqttEndpoint {
      26              :     pub secure: bool,
      27              :     pub username: Option<String>,
      28              :     pub password: Option<String>,
      29              :     pub host: String,
      30              :     pub port: u16,
      31              :     pub topic: String,
      32              : }
      33              : 
      34              : impl MqttEndpoint {
      35              :     /// 7.2 endpoint URI syntax. A URI that does not meet it fails the
      36              :     /// 5.2.15 restrictions, so the caller raises BadRequestData — 400 per
      37              :     /// Table 6.3.2-1. The message names only the redacted URI: the
      38              :     /// credentials 7.2 permits in the userinfo never reach the response body.
      39           50 :     pub fn parse(uri: &str) -> Result<Self, NgsiError> {
      40           50 :         let safe = redacted(uri);
      41              :         // IETF RFC 3986 3.1: scheme names are case-insensitive, and
      42              :         // `SinkRegistry::scheme_of` already lowercases to pick this sink.
      43           50 :         let (secure, rest) = match uri.split_once("://") {
      44           50 :             Some((s, r)) if s.eq_ignore_ascii_case("mqtts") => (true, r),
      45           42 :             Some((s, r)) if s.eq_ignore_ascii_case("mqtt") => (false, r),
      46            4 :             _ => return Err(bad(format!("not an mqtt(s) endpoint URI: {safe:?}"))),
      47              :         };
      48           46 :         let (authority, topic) = rest
      49           46 :             .split_once('/')
      50           46 :             .ok_or_else(|| bad(format!("mqtt endpoint {safe:?} has no topic")))?;
      51           42 :         if topic.is_empty() {
      52            4 :             return Err(bad(format!("mqtt endpoint {safe:?} has no topic")));
      53           38 :         }
      54           38 :         let (userinfo, hostport) = match authority.rsplit_once('@') {
      55           18 :             Some((u, h)) => (Some(u), h),
      56           20 :             None => (None, authority),
      57              :         };
      58           38 :         let (username, password) = match userinfo {
      59           20 :             None => (None, None),
      60           18 :             Some(u) => match u.split_once(':') {
      61           14 :                 Some((user, pass)) => (Some(user.to_owned()), Some(pass.to_owned())),
      62            4 :                 None => (Some(u.to_owned()), None),
      63              :             },
      64              :         };
      65              :         // Deliberately no IPv6-literal hosts — the binding's URI convention
      66              :         // (i.19) and the ETSI suite use hostnames; add bracket parsing when a
      67              :         // deployment needs it.
      68           38 :         let (host, port) = match hostport.split_once(':') {
      69            8 :             Some((h, p)) => (
      70            8 :                 h.to_owned(),
      71            8 :                 p.parse::<u16>()
      72            8 :                     .map_err(|_| bad(format!("invalid mqtt port in {safe:?}")))?,
      73              :             ),
      74           30 :             None => (hostport.to_owned(), if secure { 8883 } else { 1883 }),
      75              :         };
      76           34 :         if host.is_empty() {
      77            4 :             return Err(bad(format!("mqtt endpoint {safe:?} has no host")));
      78           30 :         }
      79           30 :         Ok(Self {
      80           30 :             secure,
      81           30 :             username,
      82           30 :             password,
      83           30 :             host,
      84           30 :             port,
      85           30 :             topic: topic.to_owned(),
      86           30 :         })
      87           50 :     }
      88              : }
      89              : 
      90              : /// Table 7.2-1 protocol parameters from `notification.endpoint.notifierInfo`.
      91              : #[derive(Debug, Clone, Copy, PartialEq)]
      92              : pub struct MqttParams {
      93              :     pub qos: u8,
      94              :     pub v5: bool,
      95              : }
      96              : 
      97              : impl Default for MqttParams {
      98           12 :     fn default() -> Self {
      99           12 :         Self { qos: 0, v5: true } // defaults per Table 7.2-1: QoS 0, mqtt5.0
     100           12 :     }
     101              : }
     102              : 
     103              : impl MqttParams {
     104            8 :     pub fn from_notifier_info<'a>(
     105            8 :         pairs: impl IntoIterator<Item = (&'a str, &'a str)>,
     106            8 :     ) -> Result<Self, NgsiError> {
     107            8 :         let mut p = Self::default();
     108            8 :         for (k, v) in pairs {
     109            8 :             match k {
     110            8 :                 "MQTT-QoS" => {
     111            4 :                     p.qos = match v {
     112            4 :                         "0" => 0,
     113            4 :                         "1" => 1,
     114            4 :                         "2" => 2,
     115            2 :                         _ => return Err(bad(format!("MQTT-QoS must be 0, 1 or 2 (got {v:?})"))),
     116              :                     }
     117              :                 }
     118            4 :                 "MQTT-Version" => {
     119            4 :                     p.v5 = match v {
     120            4 :                         "mqtt5.0" => true,
     121            4 :                         "mqtt3.1.1" => false,
     122              :                         _ => {
     123            2 :                             return Err(bad(format!(
     124            2 :                                 "MQTT-Version must be mqtt3.1.1 or mqtt5.0 (got {v:?})"
     125            2 :                             )))
     126              :                         }
     127              :                     }
     128              :                 }
     129            0 :                 _ => {} // unknown notifierInfo keys are not ours to police
     130              :             }
     131              :         }
     132            4 :         Ok(p)
     133            8 :     }
     134              : }
     135              : 
     136              : /// The 7.2 message: `{"metadata": {...}, "body": notification}`.
     137              : /// `link` is the HTTP-Link-header-formatted @context reference; per Table
     138              : /// 7.2-2 it is included only when the Content-Type is application/json
     139              : /// (with ld+json the @context travels in the body).
     140            6 : pub fn build_message(
     141            6 :     body: &Value,
     142            6 :     content_type: &str,
     143            6 :     link: Option<&str>,
     144            6 :     receiver_info: &[(String, String)],
     145            6 : ) -> Value {
     146            6 :     let mut metadata = Map::new();
     147              :     // receiverInfo goes in first: 7.2 adds its entries "additionally", while
     148              :     // Table 7.2-2 names endpoint.accept as the source of Content-Type and
     149              :     // the served @context as the source of Link. A pair keyed like one of
     150              :     // those two is an entry, never that parameter's value.
     151            8 :     for (k, v) in receiver_info {
     152            8 :         metadata.insert(k.clone(), Value::String(v.clone()));
     153            8 :     }
     154            6 :     metadata.insert("Content-Type".into(), Value::String(content_type.into()));
     155            6 :     if content_type == "application/json" {
     156            4 :         if let Some(l) = link {
     157            4 :             metadata.insert("Link".into(), Value::String(l.to_owned()));
     158            4 :         }
     159            2 :     }
     160            6 :     let mut msg = Map::new();
     161            6 :     msg.insert("metadata".into(), Value::Object(metadata));
     162            6 :     msg.insert("body".into(), body.clone());
     163            6 :     Value::Object(msg)
     164            6 : }
     165              : 
     166              : /// The key a pooled MQTT connection is shared under. Everything that changes
     167              : /// the identity the connection authenticates as (or how) must participate:
     168              : /// two subscriptions whose endpoints differ only in password must never
     169              : /// reuse one another's authenticated connection. Keys are map keys only:
     170              : /// never log them.
     171           16 : fn pool_key(ep: &MqttEndpoint, params: MqttParams) -> String {
     172              :     // The password participates via a one-way hash so the plaintext never
     173              :     // sits in a map key. DefaultHasher collisions are acceptable here — a
     174              :     // collision only merges two pool slots, it does not skip broker-side
     175              :     // authentication.
     176              :     use std::hash::{Hash, Hasher};
     177           16 :     let mut h = std::collections::hash_map::DefaultHasher::new();
     178           16 :     ep.password.hash(&mut h);
     179           16 :     format!(
     180              :         "{}:{:016x}:{}@{}:{}/v{}",
     181           16 :         ep.username.as_deref().unwrap_or(""),
     182           16 :         h.finish(),
     183              :         ep.secure,
     184              :         ep.host,
     185              :         ep.port,
     186           16 :         if params.v5 { 5 } else { 3 }
     187              :     )
     188           16 : }
     189              : 
     190              : /// The mqtts trust store, built ONCE per process. Loading the platform
     191              : /// certificate store on every connect is wasted work, and rumqttc's
     192              : /// `TlsConfiguration::default()` panics when the store is unreadable — a
     193              : /// bad cert bundle must fail the one delivery, not the broker.
     194            4 : fn shared_tls_config() -> Result<rumqttc::TlsConfiguration, NgsiError> {
     195              :     static TLS: std::sync::OnceLock<Option<rumqttc::TlsConfiguration>> = std::sync::OnceLock::new();
     196            4 :     TLS.get_or_init(|| {
     197              :         // `TlsConfiguration::default()` is the only rumqttc constructor
     198              :         // that loads the platform trust store, and it panics on failure —
     199              :         // contain that so a broken cert bundle degrades to failed mqtts
     200              :         // deliveries instead of killing the process. The failure is cached:
     201              :         // a store unreadable at first use will not become readable later.
     202            2 :         std::panic::catch_unwind(rumqttc::TlsConfiguration::default)
     203            2 :             .map_err(|_| tracing::error!("mqtts: loading the platform certificate store failed"))
     204            2 :             .ok()
     205            2 :     })
     206            4 :     .clone()
     207            4 :     .ok_or_else(|| NgsiError::InternalError("mqtts trust store unavailable".into()))
     208            4 : }
     209              : 
     210              : /// Is egress to private/loopback ranges allowed? The MQTT destination is
     211              : /// client-supplied (`notification.endpoint.uri`, 7.2), so it is governed by
     212              : /// the same deployment switch, and judged by the same address classifiers,
     213              : /// as the HTTP callbacks and @context fetches: `EgressPolicy` is the one
     214              : /// copy of both. Private egress is allowed by default (dev boxes, compose
     215              : /// stacks and the conformance mocks all live there) and
     216              : /// `ANTARES_EGRESS_ALLOW_PRIVATE=false` turns the deny on for
     217              : /// internet-exposed deployments. Read once per process.
     218            2 : fn allow_private_egress() -> bool {
     219              :     static ALLOW: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
     220            2 :     *ALLOW.get_or_init(|| EgressPolicy::from_env().allow_private)
     221            2 : }
     222              : 
     223              : /// Resolve the endpoint host ONCE and return the address to dial, or a
     224              : /// denial. Resolving for the check and then handing the NAME to the MQTT
     225              : /// client would leave a window in which the answer changes (DNS rebinding):
     226              : /// the connector would dial an address the policy never saw. The address
     227              : /// this returns is the address dialled, so check and connect see the same
     228              : /// answer by construction. A host that cannot be resolved is a DENIAL — a
     229              : /// destination the policy could not judge is never dialled — and the
     230              : /// resolver runs under the sink's own deadline.
     231           34 : async fn checked_addr(
     232           34 :     host: &str,
     233           34 :     port: u16,
     234           34 :     allow_private: bool,
     235           34 :     dns_timeout: Duration,
     236           34 : ) -> Result<std::net::SocketAddr, NgsiError> {
     237           34 :     let denied = || {
     238           20 :         NgsiError::InternalError(format!(
     239           20 :             "mqtt egress to {host}:{port} denied (instance metadata or private range)"
     240           20 :         ))
     241           20 :     };
     242           34 :     let addrs: Vec<std::net::SocketAddr> = match host
     243           34 :         .trim_matches(['[', ']'])
     244           34 :         .parse::<std::net::IpAddr>()
     245              :     {
     246              :         // an IP literal needs no resolver, and is judged by the same rules
     247           28 :         Ok(ip) => vec![std::net::SocketAddr::new(ip, port)],
     248            6 :         Err(_) => tokio::time::timeout(dns_timeout, tokio::net::lookup_host((host, port)))
     249            6 :             .await
     250            6 :             .map_err(|_| {
     251            0 :                 NgsiError::InternalError(format!("mqtt egress: resolving {host} timed out"))
     252            0 :             })?
     253            6 :             .map_err(|e| NgsiError::InternalError(format!("mqtt egress: resolving {host}: {e}")))?
     254            4 :             .collect(),
     255              :     };
     256           32 :     addrs
     257           32 :         .into_iter()
     258           34 :         .find(|a| {
     259           34 :             !EgressPolicy::ip_is_metadata(a.ip())
     260           24 :                 && (allow_private || !EgressPolicy::ip_is_private(a.ip()))
     261           34 :         })
     262           32 :         .ok_or_else(denied)
     263           34 : }
     264              : 
     265              : /// What to hand rumqttc as the broker address. Plain MQTT dials the checked
     266              : /// ADDRESS, which pins the resolution the policy judged (and makes the
     267              : /// event loop's own re-dial after a dropped connection reuse it instead of
     268              : /// resolving the name again, unchecked). mqtts keeps the host NAME: rumqttc
     269              : /// verifies the server certificate against the string it is given, so an
     270              : /// address there would demand an IP SAN and break certificate verification
     271              : /// against every ordinary broker certificate. For mqtts the check above
     272              : /// still gates the connect, and the certificate name check is what stops a
     273              : /// changed answer from impersonating the endpoint.
     274            6 : fn dial_host(ep: &MqttEndpoint, addr: std::net::SocketAddr) -> String {
     275            6 :     if ep.secure {
     276            2 :         ep.host.clone()
     277              :     } else {
     278            4 :         addr.ip().to_string()
     279              :     }
     280            6 : }
     281              : 
     282              : /// One pooled connection: the client plus its event-loop pump task.
     283              : enum Client {
     284              :     V3(rumqttc::AsyncClient),
     285              :     V5(rumqttc::v5::AsyncClient),
     286              : }
     287              : 
     288              : struct Conn {
     289              :     client: Client,
     290              :     pump: tokio::task::JoinHandle<()>,
     291              :     last_used: Instant,
     292              : }
     293              : 
     294              : impl Drop for Conn {
     295            0 :     fn drop(&mut self) {
     296            0 :         self.pump.abort();
     297            0 :     }
     298              : }
     299              : 
     300              : /// MQTT delivery with a bounded per-endpoint connection pool (bounded
     301              : /// WITH eviction; timeouts fixed at construction).
     302              : pub struct MqttSink {
     303              :     pool: Mutex<HashMap<String, Conn>>,
     304              :     cap: usize,
     305              :     timeout: Duration,
     306              : }
     307              : 
     308              : impl Default for MqttSink {
     309         3184 :     fn default() -> Self {
     310         3184 :         Self::new(32, Duration::from_secs(5))
     311         3184 :     }
     312              : }
     313              : 
     314              : impl MqttSink {
     315         3186 :     pub fn new(cap: usize, timeout: Duration) -> Self {
     316         3186 :         Self {
     317         3186 :             pool: Mutex::new(HashMap::new()),
     318         3186 :             cap,
     319         3186 :             timeout,
     320         3186 :         }
     321         3186 :     }
     322              : 
     323              :     /// Deliver one notification message. `message` is the 7.2 wrapper from
     324              :     /// [`build_message`], serialized by the caller once per subscription.
     325            2 :     pub async fn publish_message(
     326            2 :         &self,
     327            2 :         ep: &MqttEndpoint,
     328            2 :         params: MqttParams,
     329            2 :         message: &[u8],
     330            2 :     ) -> Result<(), NgsiError> {
     331            2 :         let key = pool_key(ep, params);
     332              :         // one retry with a fresh connection: a pooled client whose broker
     333              :         // restarted fails the first publish; a dead broker fails both.
     334            2 :         for attempt in 0..2 {
     335            2 :             let conn = match self.checkout(&key) {
     336            0 :                 Some(c) => c,
     337            2 :                 None => self.connect(ep, params).await?,
     338              :             };
     339            0 :             let published = tokio::time::timeout(
     340            0 :                 self.timeout,
     341            0 :                 Self::publish(&conn.client, &ep.topic, params.qos, message),
     342            0 :             )
     343            0 :             .await;
     344            0 :             match published {
     345            0 :                 Ok(Ok(())) if !conn.pump.is_finished() => {
     346            0 :                     self.checkin(key, conn);
     347            0 :                     return Ok(());
     348              :                 }
     349            0 :                 _ if attempt == 0 => continue, // drop conn, retry fresh
     350              :                 Ok(Ok(())) => {
     351            0 :                     return Err(NgsiError::InternalError(
     352            0 :                         "mqtt connection lost during publish".into(),
     353            0 :                     ))
     354              :                 }
     355            0 :                 Ok(Err(e)) => return Err(NgsiError::InternalError(format!("mqtt publish: {e}"))),
     356              :                 Err(_) => {
     357            0 :                     return Err(NgsiError::InternalError(format!(
     358            0 :                         "mqtt publish to {}:{} timed out",
     359            0 :                         ep.host, ep.port
     360            0 :                     )))
     361              :                 }
     362              :             }
     363              :         }
     364            0 :         Err(NgsiError::InternalError(
     365            0 :             "mqtt publish exhausted its retry without a verdict".into(),
     366            0 :         ))
     367            2 :     }
     368              : 
     369            0 :     async fn publish(client: &Client, topic: &str, qos: u8, payload: &[u8]) -> Result<(), String> {
     370            0 :         match client {
     371            0 :             Client::V3(c) => {
     372            0 :                 let qos = rumqttc::qos(qos).map_err(|e| e.to_string())?;
     373            0 :                 c.publish(topic, qos, false, payload.to_vec())
     374            0 :                     .await
     375            0 :                     .map_err(|e| e.to_string())
     376              :             }
     377            0 :             Client::V5(c) => {
     378            0 :                 let qos =
     379            0 :                     rumqttc::v5::mqttbytes::qos(qos).ok_or_else(|| format!("invalid QoS {qos}"))?;
     380            0 :                 c.publish(topic, qos, false, payload.to_vec())
     381            0 :                     .await
     382            0 :                     .map_err(|e| e.to_string())
     383              :             }
     384              :         }
     385            0 :     }
     386              : 
     387            2 :     fn checkout(&self, key: &str) -> Option<Conn> {
     388            2 :         self.pool
     389            2 :             .lock()
     390            2 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
     391            2 :             .remove(key)
     392            2 :     }
     393              : 
     394            0 :     fn checkin(&self, key: String, mut conn: Conn) {
     395            0 :         conn.last_used = Instant::now();
     396            0 :         let mut pool = self
     397            0 :             .pool
     398            0 :             .lock()
     399            0 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     400            0 :         pool.retain(|_, c| !c.pump.is_finished());
     401            0 :         pool.insert(key, conn);
     402              :         // bounded with eviction: drop the least-recently-used overflow.
     403            0 :         while pool.len() > self.cap {
     404            0 :             if let Some(oldest) = pool
     405            0 :                 .iter()
     406            0 :                 .min_by_key(|(_, c)| c.last_used)
     407            0 :                 .map(|(k, _)| k.clone())
     408            0 :             {
     409            0 :                 pool.remove(&oldest);
     410            0 :             }
     411              :         }
     412            0 :     }
     413              : 
     414              :     /// Connect and wait for ConnAck (a dead broker must fail delivery, not
     415              :     /// queue forever), then hand the event loop to a pump task.
     416            2 :     async fn connect(&self, ep: &MqttEndpoint, params: MqttParams) -> Result<Conn, NgsiError> {
     417              :         // A monotonic counter, not a timestamp: `Instant::now().elapsed()` is
     418              :         // ~0 for every caller, so two connects in one process would claim the
     419              :         // same client id and the broker would kick the older session.
     420              :         static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
     421            2 :         let id = format!(
     422              :             "antares-{}-{}",
     423            2 :             std::process::id(),
     424            2 :             SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
     425              :         );
     426            2 :         let refused = |e: String| {
     427            0 :             NgsiError::InternalError(format!("mqtt connect {}:{}: {e}", ep.host, ep.port))
     428            0 :         };
     429              :         // Egress policy first: resolve once, judge the answer, dial what was
     430              :         // judged. Nothing below opens a socket to an unchecked destination.
     431            2 :         let addr = checked_addr(&ep.host, ep.port, allow_private_egress(), self.timeout).await?;
     432            0 :         let dial = dial_host(ep, addr);
     433            0 :         if params.v5 {
     434            0 :             let mut opts = rumqttc::v5::MqttOptions::new(id, dial, addr.port());
     435            0 :             opts.set_keep_alive(Duration::from_secs(30));
     436            0 :             if let Some(u) = &ep.username {
     437            0 :                 opts.set_credentials(u, ep.password.as_deref().unwrap_or(""));
     438            0 :             }
     439            0 :             if ep.secure {
     440            0 :                 opts.set_transport(rumqttc::Transport::Tls(shared_tls_config()?));
     441            0 :             }
     442            0 :             let (client, mut eventloop) = rumqttc::v5::AsyncClient::new(opts, 16);
     443            0 :             tokio::time::timeout(self.timeout, async {
     444              :                 loop {
     445            0 :                     match eventloop.poll().await {
     446              :                         Ok(rumqttc::v5::Event::Incoming(
     447              :                             rumqttc::v5::mqttbytes::v5::Packet::ConnAck(_),
     448            0 :                         )) => return Ok(()),
     449            0 :                         Ok(_) => {}
     450            0 :                         Err(e) => return Err(e.to_string()),
     451              :                     }
     452              :                 }
     453            0 :             })
     454            0 :             .await
     455            0 :             .map_err(|_| refused("connect timeout".into()))?
     456            0 :             .map_err(refused)?;
     457            0 :             let pump = tokio::spawn(async move { while eventloop.poll().await.is_ok() {} });
     458            0 :             Ok(Conn {
     459            0 :                 client: Client::V5(client),
     460            0 :                 pump,
     461            0 :                 last_used: Instant::now(),
     462            0 :             })
     463              :         } else {
     464            0 :             let mut opts = rumqttc::MqttOptions::new(id, dial, addr.port());
     465            0 :             opts.set_keep_alive(Duration::from_secs(30));
     466            0 :             if let Some(u) = &ep.username {
     467            0 :                 opts.set_credentials(u, ep.password.as_deref().unwrap_or(""));
     468            0 :             }
     469            0 :             if ep.secure {
     470            0 :                 opts.set_transport(rumqttc::Transport::Tls(shared_tls_config()?));
     471            0 :             }
     472            0 :             let (client, mut eventloop) = rumqttc::AsyncClient::new(opts, 16);
     473            0 :             tokio::time::timeout(self.timeout, async {
     474              :                 loop {
     475            0 :                     match eventloop.poll().await {
     476            0 :                         Ok(rumqttc::Event::Incoming(rumqttc::Packet::ConnAck(_))) => return Ok(()),
     477            0 :                         Ok(_) => {}
     478            0 :                         Err(e) => return Err(e.to_string()),
     479              :                     }
     480              :                 }
     481            0 :             })
     482            0 :             .await
     483            0 :             .map_err(|_| refused("connect timeout".into()))?
     484            0 :             .map_err(refused)?;
     485            0 :             let pump = tokio::spawn(async move { while eventloop.poll().await.is_ok() {} });
     486            0 :             Ok(Conn {
     487            0 :                 client: Client::V3(client),
     488            0 :                 pump,
     489            0 :                 last_used: Instant::now(),
     490            0 :             })
     491              :         }
     492            2 :     }
     493              : }
     494              : 
     495              : /// Clause 7: the mqtt(s) binding. `parse_endpoint` holds Table 7.2-1 (the
     496              : /// endpoint URI syntax and the notifier parameters) at subscription
     497              : /// creation; `deliver` publishes the 7.2 message — `{"metadata": …,
     498              : /// "body": <Notification>}` — on the endpoint's topic.
     499              : impl crate::NotificationSink for MqttSink {
     500          120 :     fn schemes(&self) -> &'static [&'static str] {
     501          120 :         &["mqtt", "mqtts"]
     502          120 :     }
     503              : 
     504            0 :     fn parse_endpoint(&self, uri: &str, notifier_info: &[(&str, &str)]) -> Result<(), NgsiError> {
     505            0 :         MqttEndpoint::parse(uri)?;
     506            0 :         MqttParams::from_notifier_info(notifier_info.iter().copied())?;
     507            0 :         Ok(())
     508            0 :     }
     509              : 
     510            0 :     fn deliver<'a>(
     511            0 :         &'a self,
     512            0 :         uri: &'a str,
     513            0 :         out: &'a crate::Outbound,
     514            0 :         _timeout: Duration,
     515            0 :     ) -> crate::DeliveryFuture<'a> {
     516            0 :         Box::pin(async move {
     517              :             // Creation validated the endpoint (7.2 / Table 7.2-1); a parse
     518              :             // failure here means a hand-edited row. The message names only
     519              :             // the redacted URI: 7.2 permits credentials in the userinfo.
     520            0 :             let Ok(ep) = MqttEndpoint::parse(uri) else {
     521            0 :                 return Err(crate::DeliveryError::failed(format!(
     522            0 :                     "mqtt endpoint {:?} unusable",
     523            0 :                     redacted(uri)
     524            0 :                 )));
     525              :             };
     526            0 :             let Ok(params) = MqttParams::from_notifier_info(out.notifier_pairs()) else {
     527            0 :                 return Err(crate::DeliveryError::failed(format!(
     528            0 :                     "mqtt notifierInfo of {:?} unusable",
     529            0 :                     redacted(uri)
     530            0 :                 )));
     531              :             };
     532            0 :             let msg = build_message(&out.body, &out.accept, Some(&out.link), &out.receiver_info);
     533            0 :             let bytes = antares_model::ordered_vec(&msg);
     534            0 :             self.publish_message(&ep, params, &bytes)
     535            0 :                 .await
     536              :                 // broker/socket-level failure — keep the timeout guard
     537            0 :                 .map_err(|e| crate::DeliveryError::timeout(e.to_string()))
     538            0 :         })
     539            0 :     }
     540              : }
     541              : 
     542              : #[cfg(test)]
     543              : mod tests {
     544              :     use super::*;
     545              :     use serde_json::json;
     546              : 
     547              :     #[test]
     548            2 :     fn parses_endpoint_variants() {
     549            2 :         let e = MqttEndpoint::parse("mqtt://host/topic").expect("plain");
     550            2 :         assert_eq!(
     551              :             e,
     552            2 :             MqttEndpoint {
     553            2 :                 secure: false,
     554            2 :                 username: None,
     555            2 :                 password: None,
     556            2 :                 host: "host".into(),
     557            2 :                 port: 1883,
     558            2 :                 topic: "topic".into()
     559            2 :             }
     560              :         );
     561            2 :         let e = MqttEndpoint::parse("mqtt://host:8085/a/b/c").expect("port+subtopics");
     562            2 :         assert_eq!(e.port, 8085);
     563            2 :         assert_eq!(e.topic, "a/b/c");
     564            2 :         let e = MqttEndpoint::parse("mqtt://user@host/t").expect("user");
     565            2 :         assert_eq!(e.username.as_deref(), Some("user"));
     566            2 :         assert_eq!(e.password, None);
     567            2 :         let e = MqttEndpoint::parse("mqtt://u:p@host:9001/t").expect("user+pass+port");
     568            2 :         assert_eq!(e.username.as_deref(), Some("u"));
     569            2 :         assert_eq!(e.password.as_deref(), Some("p"));
     570            2 :         assert_eq!(e.port, 9001);
     571            2 :         let e = MqttEndpoint::parse("mqtts://host/t").expect("tls");
     572            2 :         assert!(e.secure);
     573            2 :         assert_eq!(e.port, 8883, "mqtts default port");
     574            2 :     }
     575              : 
     576              :     /// IETF RFC 3986 3.1: scheme names are case-insensitive.
     577              :     /// `SinkRegistry::scheme_of` lowercases before it picks this sink, so an
     578              :     /// uppercase-scheme endpoint is routed here and this parser decides —
     579              :     /// rejecting it would 400 an endpoint URI the registry says it serves.
     580              :     #[test]
     581            2 :     fn the_scheme_is_matched_case_insensitively() {
     582            2 :         assert!(
     583            2 :             MqttEndpoint::parse("MQTT://host/topic")
     584            2 :                 .expect("upper")
     585            2 :                 .port
     586            2 :                 == 1883
     587              :         );
     588            2 :         let e = MqttEndpoint::parse("MQTTS://host/t").expect("upper tls");
     589            2 :         assert!(e.secure);
     590            2 :         assert_eq!(e.port, 8883);
     591            2 :         assert!(MqttEndpoint::parse("MqTt://host/t").is_ok());
     592            2 :     }
     593              : 
     594              :     #[test]
     595            2 :     fn rejects_bad_endpoints() {
     596           10 :         for uri in [
     597            2 :             "http://host/topic",
     598            2 :             "mqtt://host",
     599            2 :             "mqtt://host/",
     600            2 :             "mqtt:///topic",
     601            2 :             "mqtt://host:notaport/t",
     602            2 :         ] {
     603           10 :             assert!(MqttEndpoint::parse(uri).is_err(), "{uri} must be rejected");
     604              :         }
     605            2 :     }
     606              : 
     607              :     /// 7.2 endpoint URIs may carry credentials in the userinfo
     608              :     /// (`mqtt[s]://<username>:<password>@host`). A parse failure is answered
     609              :     /// to the client as BadRequestData (5.8.1.4, 400 per Table 6.3.2-1) and
     610              :     /// the message becomes the 5.5.3 ProblemDetails `detail`, so no password
     611              :     /// may appear in it.
     612              :     #[test]
     613            2 :     fn parse_errors_redact_endpoint_userinfo() {
     614           10 :         for uri in [
     615            2 :             "mqtt://user:hunter2@host",            // no topic
     616            2 :             "mqtt://user:hunter2@host/",           // empty topic
     617            2 :             "mqtt://user:hunter2@host:notaport/t", // bad port
     618            2 :             "mqtts://user:hunter2@/t",             // no host
     619            2 :             "http://user:hunter2@host/t",          // wrong scheme
     620            2 :         ] {
     621           10 :             let NgsiError::BadRequestData(msg) =
     622           10 :                 MqttEndpoint::parse(uri).expect_err(&format!("{uri} must be rejected"))
     623              :             else {
     624            0 :                 panic!("{uri} must be BadRequestData (400, Table 6.3.2-1)");
     625              :             };
     626           10 :             assert!(
     627           10 :                 !msg.contains("hunter2"),
     628              :                 "the password leaked into the 400 detail: {msg}"
     629              :             );
     630           10 :             assert!(
     631           10 :                 !msg.contains("user:"),
     632              :                 "the userinfo leaked into the 400 detail: {msg}"
     633              :             );
     634              :             // the detail must still be useful: scheme and host survive
     635           10 :             assert!(
     636           10 :                 msg.contains("host") || uri.contains("@/"),
     637              :                 "the redacted detail lost the host: {msg}"
     638              :             );
     639              :         }
     640            2 :     }
     641              : 
     642              :     #[test]
     643            2 :     fn notifier_info_defaults_and_validation() {
     644            2 :         let p = MqttParams::from_notifier_info([]).expect("defaults");
     645            2 :         assert_eq!(p, MqttParams { qos: 0, v5: true });
     646            2 :         let p = MqttParams::from_notifier_info([("MQTT-QoS", "2"), ("MQTT-Version", "mqtt3.1.1")])
     647            2 :             .expect("explicit");
     648            2 :         assert_eq!(p, MqttParams { qos: 2, v5: false });
     649            2 :         assert!(MqttParams::from_notifier_info([("MQTT-QoS", "3")]).is_err());
     650            2 :         assert!(MqttParams::from_notifier_info([("MQTT-Version", "mqtt4")]).is_err());
     651            2 :     }
     652              : 
     653              :     /// The pool key must separate sessions by credentials: same user with
     654              :     /// two different passwords = two different authenticated principals,
     655              :     /// which must never share one session. The plaintext password itself
     656              :     /// must not appear in the key.
     657              :     #[test]
     658            2 :     fn pool_key_separates_credentials() {
     659            2 :         let p = MqttParams::default();
     660            2 :         let a = MqttEndpoint::parse("mqtt://u:secret-one@host/t").expect("a");
     661            2 :         let b = MqttEndpoint::parse("mqtt://u:secret-two@host/t").expect("b");
     662            2 :         let c = MqttEndpoint::parse("mqtt://u:secret-one@host/t").expect("c");
     663            2 :         assert_ne!(
     664            2 :             pool_key(&a, p),
     665            2 :             pool_key(&b, p),
     666              :             "different passwords must not share an authenticated session"
     667              :         );
     668            2 :         assert_eq!(
     669            2 :             pool_key(&a, p),
     670            2 :             pool_key(&c, p),
     671              :             "identical endpoints must keep pooling"
     672              :         );
     673            2 :         assert!(
     674            2 :             !pool_key(&a, p).contains("secret-one"),
     675              :             "the plaintext password must never appear in the key"
     676              :         );
     677              :         // no password vs some password are different principals too
     678            2 :         let none = MqttEndpoint::parse("mqtt://u@host/t").expect("none");
     679            2 :         assert_ne!(pool_key(&a, p), pool_key(&none, p));
     680            2 :     }
     681              : 
     682              :     /// The mqtts trust store is loaded once and the same shared rustls
     683              :     /// config is handed to every connect. (The failure path — an unreadable
     684              :     /// platform store — cannot be simulated in a unit test; the helper's
     685              :     /// error contract covers it.)
     686              :     #[test]
     687            2 :     fn tls_config_is_built_once_and_shared() {
     688            2 :         let a = shared_tls_config().expect("first load");
     689            2 :         let b = shared_tls_config().expect("second load");
     690            2 :         let (rumqttc::TlsConfiguration::Rustls(a), rumqttc::TlsConfiguration::Rustls(b)) = (a, b)
     691              :         else {
     692            0 :             panic!("expected the injected-rustls variant");
     693              :         };
     694            2 :         assert!(
     695            2 :             std::sync::Arc::ptr_eq(&a, &b),
     696              :             "each call built a fresh trust store instead of sharing one"
     697              :         );
     698            2 :     }
     699              : 
     700              :     /// The MQTT destination is client-supplied (`notification.endpoint.uri`,
     701              :     /// 7.2), so it is an egress target: the cloud instance-metadata range is
     702              :     /// refused before any socket is opened, whatever the private-egress
     703              :     /// switch says, and the refusal must not echo the endpoint credentials.
     704              :     #[tokio::test]
     705            2 :     async fn deliver_refuses_instance_metadata_endpoint() {
     706            2 :         let ep = MqttEndpoint::parse("mqtt://user:hunter2@169.254.169.254/t").expect("parse");
     707            2 :         let sink = MqttSink::new(2, Duration::from_millis(250));
     708            2 :         let err = sink
     709            2 :             .publish_message(&ep, MqttParams::default(), b"{}")
     710            2 :             .await
     711            2 :             .expect_err("the metadata range must never be dialled");
     712            2 :         let msg = err.to_string();
     713            2 :         assert!(
     714            2 :             msg.contains("denied"),
     715              :             "expected an egress denial, got: {msg}"
     716              :         );
     717            2 :         assert!(
     718            2 :             !msg.contains("hunter2"),
     719            2 :             "the endpoint password leaked into the delivery error: {msg}"
     720            2 :         );
     721            2 :     }
     722              : 
     723              :     /// Egress classification, restated from the HTTP side's policy: the
     724              :     /// metadata range is refused unconditionally, private ranges only when
     725              :     /// the deployment switched private egress off, and IPv4-mapped IPv6
     726              :     /// spellings are judged as their IPv4 selves.
     727              :     #[tokio::test]
     728            2 :     async fn checked_addr_applies_the_egress_rules_to_resolved_addresses() {
     729            2 :         let d = Duration::from_secs(2);
     730              :         // metadata: denied with private egress ALLOWED (the default)
     731              :         // the 6to4 spelling too (IETF RFC 3056: the two segments after the
     732              :         // 2002:: prefix ARE the IPv4 destination), which proves this binding
     733              :         // goes through the shared classifier rather than its own check
     734            8 :         for host in [
     735            2 :             "169.254.169.254",
     736            2 :             "::ffff:169.254.169.254",
     737            2 :             "fd00:ec2::254",
     738            2 :             "2002:a9fe:a9fe::",
     739            2 :         ] {
     740            8 :             let e = checked_addr(host, 1883, true, d)
     741            8 :                 .await
     742            8 :                 .expect_err("metadata range must be refused whatever the switch says");
     743            8 :             assert!(e.to_string().contains("denied"), "{host}: {e}");
     744              :         }
     745              :         // loopback and RFC 1918: allowed by default, refused when the
     746              :         // deployment turns private egress off
     747           10 :         for host in [
     748            2 :             "127.0.0.1",
     749            2 :             "::ffff:127.0.0.1",
     750            2 :             "10.1.2.3",
     751            2 :             "::1",
     752            2 :             "localhost",
     753            2 :         ] {
     754           10 :             let ok = checked_addr(host, 1883, true, d)
     755           10 :                 .await
     756           10 :                 .unwrap_or_else(|e| panic!("{host} must be reachable by default: {e}"));
     757           10 :             assert_eq!(ok.port(), 1883);
     758           10 :             assert!(
     759           10 :                 checked_addr(host, 1883, false, d).await.is_err(),
     760              :                 "{host} must be refused with private egress off"
     761              :             );
     762              :         }
     763              :         // a public literal clears the strict policy and comes back as the
     764              :         // ADDRESS to dial — the resolution the check judged, pinned
     765            2 :         let a = checked_addr("93.184.216.34", 8883, false, d)
     766            2 :             .await
     767            2 :             .expect("public address allowed");
     768            2 :         assert_eq!(a.to_string(), "93.184.216.34:8883");
     769              :         // a name that cannot be resolved is a denial, not a pass-through
     770            2 :         assert!(
     771            2 :             checked_addr("no-such-host.invalid", 1883, true, d)
     772            2 :                 .await
     773            2 :                 .is_err(),
     774            2 :             "an unresolvable destination must not be dialled"
     775            2 :         );
     776            2 :     }
     777              : 
     778              :     /// The address the policy judged is the address dialled — except for
     779              :     /// mqtts, where the certificate is verified against the host NAME.
     780              :     #[test]
     781            2 :     fn dial_host_pins_the_address_and_keeps_the_tls_name() {
     782            2 :         let plain = MqttEndpoint::parse("mqtt://broker.example/t").expect("plain");
     783            2 :         let addr = "203.0.113.7:1883".parse().expect("addr");
     784            2 :         assert_eq!(dial_host(&plain, addr), "203.0.113.7");
     785            2 :         let secure = MqttEndpoint::parse("mqtts://broker.example/t").expect("secure");
     786            2 :         assert_eq!(
     787            2 :             dial_host(&secure, addr),
     788              :             "broker.example",
     789              :             "mqtts must dial the name so the certificate name check still applies"
     790              :         );
     791              :         // IPv6 comes back unbracketed, which is what rumqttc resolves
     792            2 :         let v6 = "[2001:db8::1]:1883".parse().expect("v6 addr");
     793            2 :         assert_eq!(dial_host(&plain, v6), "2001:db8::1");
     794            2 :     }
     795              : 
     796              :     #[test]
     797            2 :     fn message_wrapper_shape() {
     798            2 :         let body = json!({"id": "urn:n:1", "type": "Notification"});
     799            2 :         let m = build_message(
     800            2 :             &body,
     801            2 :             "application/json",
     802            2 :             Some("<https://ctx>; rel=\"http://www.w3.org/ns/json-ld#context\""),
     803            2 :             &[("MyKey".into(), "MyValue".into())],
     804              :         );
     805            2 :         assert_eq!(m["body"], body);
     806            2 :         assert_eq!(m["metadata"]["Content-Type"], "application/json");
     807            2 :         assert!(m["metadata"]["Link"]
     808            2 :             .as_str()
     809            2 :             .expect("link present")
     810            2 :             .contains("json-ld#context"));
     811            2 :         assert_eq!(m["metadata"]["MyKey"], "MyValue");
     812              : 
     813              :         // ld+json: @context is in the body, no Link in metadata (Table 7.2-2)
     814            2 :         let m = build_message(&body, "application/ld+json", Some("<x>"), &[]);
     815            2 :         assert_eq!(m["metadata"]["Content-Type"], "application/ld+json");
     816            2 :         assert!(m["metadata"].get("Link").is_none());
     817            2 :     }
     818              : 
     819              :     /// Table 7.2-2 names the source of two metadata keys: `Content-Type`
     820              :     /// comes from `endpoint.accept` and `Link` from the served @context.
     821              :     /// receiverInfo entries are added "additionally", so one keyed like a
     822              :     /// table parameter cannot become that parameter's value — a subscriber
     823              :     /// would otherwise decide the MIME type of its own notifications
     824              :     /// against 6.3.8's "changed by means of the endpoint.accept member",
     825              :     /// and could strip the @context reference the clause requires.
     826              :     #[test]
     827            2 :     fn receiver_info_cannot_take_over_a_table_7_2_2_parameter() {
     828            2 :         let body = json!({"id": "urn:n:1", "type": "Notification"});
     829            2 :         let link = "<https://ctx>; rel=\"http://www.w3.org/ns/json-ld#context\"";
     830            2 :         let m = build_message(
     831            2 :             &body,
     832            2 :             "application/json",
     833            2 :             Some(link),
     834            2 :             &[
     835            2 :                 ("Content-Type".into(), "text/plain".into()),
     836            2 :                 ("Link".into(), "<https://evil>".into()),
     837            2 :                 ("NGSILD-Tenant".into(), "acme".into()),
     838            2 :             ],
     839              :         );
     840            2 :         assert_eq!(m["metadata"]["Content-Type"], "application/json");
     841            2 :         assert_eq!(m["metadata"]["Link"], link);
     842              :         // every other pair is still added, this one by the caller (6.3.22)
     843            2 :         assert_eq!(m["metadata"]["NGSILD-Tenant"], "acme");
     844            2 :     }
     845              : }
        

Generated by: LCOV version 2.0-1