1use crate::cache::actor::{CacheLookupCommand, CacheResponse};
116use crate::cache::storage::SubscriptionInfo;
117use crate::{
118 ContentId, OTL_CACHE_DROP_REASON_KEY, OTL_CACHE_DROP_REASON_PEER_CACHE_FULL,
119 OTL_CACHE_DROP_REASON_SUBSCRIPTION_CACHE_FULL, OTL_YANG_PUSH_DECODE_ERROR_ID_KEY,
120 OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY, OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
121 OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
122};
123use netgauze_netconf_proto::yang_push::subscription::YangPushModuleVersion;
124use netgauze_netconf_proto::yang_push::types::SubscriptionId;
125use netgauze_udp_notif_pkt::decoded::{UdpNotifPacketDecoded, UdpNotifPayload};
126use netgauze_udp_notif_pkt::notification::{NotificationVariant, SubscriptionStartedModified};
127use netgauze_udp_notif_pkt::raw::UdpNotifPacket;
128use netgauze_udp_notif_service::{OTL_UDP_NOTIF_PUBLISHER_ID_KEY, UdpNotifRequest};
129use rustc_hash::FxHashMap;
130use std::net::{IpAddr, SocketAddr};
131use std::sync::Arc;
132use tokio::sync::mpsc;
133use tracing::{debug, info, trace, warn};
134use yang5::data::{DataFormat, DataOperation, DataParserFlags, DataValidationFlags};
135
136#[derive(Debug)]
137struct CachedSubscription {
138 cached_content_id: Option<ContentId>,
139 subscription_info: SubscriptionInfo,
140 yang_ctx: Option<yang5::context::Context>,
141 cached_packets: Vec<Arc<UdpNotifRequest>>,
142}
143
144#[derive(Debug, Default)]
145struct CachedPeerSubscriptions {
146 subscriptions: FxHashMap<SubscriptionId, CachedSubscription>,
147}
148
149#[derive(Debug, Clone)]
150pub struct ValidationStats {
151 pub messages_received: opentelemetry::metrics::Counter<u64>,
152 pub messages_decoding_success: opentelemetry::metrics::Counter<u64>,
153 pub messages_decoding_fail: opentelemetry::metrics::Counter<u64>,
154 pub cache_request_by_subscription_info: opentelemetry::metrics::Counter<u64>,
155 pub cache_request_by_subscription_id: opentelemetry::metrics::Counter<u64>,
156 pub cached_packets: opentelemetry::metrics::Gauge<u64>,
157 pub cache_drop: opentelemetry::metrics::Counter<u64>,
158 pub cache_drain: opentelemetry::metrics::Counter<u64>,
159 pub cache_yang_ctx_created: opentelemetry::metrics::Counter<u64>,
160 pub cache_yang_ctx_invalid: opentelemetry::metrics::Counter<u64>,
161 pub cache_yang_ctx_empty: opentelemetry::metrics::Counter<u64>,
162 pub validation_success: opentelemetry::metrics::Counter<u64>,
163 pub validation_invalid: opentelemetry::metrics::Counter<u64>,
164 pub validation_malformed: opentelemetry::metrics::Counter<u64>,
165 pub validation_skip: opentelemetry::metrics::Counter<u64>,
166 pub messages_sent: opentelemetry::metrics::Counter<u64>,
167}
168
169impl ValidationStats {
170 pub fn new(meter: opentelemetry::metrics::Meter) -> Self {
171 let messages_received = meter
172 .u64_counter("netgauze.collector.yang_push.validation.messages.received")
173 .with_description(
174 "Number of Yang Push messages received for validation (before decoding)",
175 )
176 .build();
177 let messages_decoding_success = meter
178 .u64_counter("netgauze.collector.yang_push.validation.messages.decode.success")
179 .with_description("Number of Yang Push messages decoded successfully (UDP-Notif payload read successfully)")
180 .build();
181 let messages_decoding_fail = meter
182 .u64_counter("netgauze.collector.yang_push.validation.messages.decode.fail")
183 .with_description("Number of Yang Push messages dropped because of decoding errors (Couldn't read UDP-Notif payload)")
184 .build();
185 let cache_request_by_subscription_info = meter
186 .u64_counter("netgauze.collector.yang_push.validation.messages.cache.requests.by.subscription_info")
187 .with_description("Number of cache requests by subscription info (from subscription-start or subscription-modified messages) to retrieve the schemas for YANG-Push subscriptions")
188 .build();
189 let cache_request_by_subscription_id = meter
190 .u64_counter("netgauze.collector.yang_push.validation.messages.cache.requests.by.subscription_info")
191 .with_description("Number of cache requests by Subscription ID to retrieve the schemas for YANG-Push subscriptions")
192 .build();
193 let cached_packets = meter
194 .u64_gauge("netgauze.collector.yang_push.validation.cache.packets")
195 .with_description("Number of Yang Push message cached")
196 .build();
197 let cache_drop = meter
198 .u64_counter("netgauze.collector.yang_push.validation.cache.drop")
199 .with_description("Number of Yang Push messages dropped because of cache is full")
200 .build();
201 let cache_drain = meter
202 .u64_counter("netgauze.collector.yang_push.validation.cache.drain")
203 .with_description("Number of Yang Push messages popped out of the cache and send is going to the validation step")
204 .build();
205 let cache_yang_ctx_created = meter
206 .u64_counter("netgauze.collector.yang_push.validation.cache.yang.ctx.created")
207 .with_description("Number of libyang validation context that are successfully created")
208 .build();
209 let cache_yang_ctx_invalid = meter
210 .u64_counter("netgauze.collector.yang_push.validation.cache.yang.ctx.invalid")
211 .with_description(
212 "Number of libyang validation context that are invalid (e.g., missing schema)",
213 )
214 .build();
215 let cache_yang_ctx_empty = meter
216 .u64_counter("netgauze.collector.yang_push.validation.cache.yang.ctx.empty")
217 .with_description("Number of libyang validation context that are empty (e.g., schema loading from the router failed)")
218 .build();
219 let validation_malformed = meter
220 .u64_counter("netgauze.collector.yang_push.validation.malformed")
221 .with_description(
222 "Number of Yang Push messages dropped because they are malformed; e.g., missing subscription info",
223 )
224 .build();
225 let validation_success = meter
226 .u64_counter("netgauze.collector.yang_push.validation.success")
227 .with_description("Number of Yang Push messages successfully validated")
228 .build();
229 let validation_invalid = meter
230 .u64_counter("netgauze.collector.yang_push.validation.invalid")
231 .with_description("Number of Yang Push messages dropped because of validation errors")
232 .build();
233 let validation_skip = meter
234 .u64_counter("netgauze.collector.yang_push.validation.skipped")
235 .with_description("Number of Yang Push skipped the validation step because the subscription is not found in the cache")
236 .build();
237 let messages_sent = meter
238 .u64_counter("netgauze.collector.yang_push.validation.messages.sent")
239 .with_description("Number of Telemetry Messages successfully sent upstream")
240 .build();
241 Self {
242 messages_received,
243 messages_decoding_success,
244 messages_decoding_fail,
245 cache_request_by_subscription_info,
246 cache_request_by_subscription_id,
247 cached_packets,
248 cache_drop,
249 cache_drain,
250 cache_yang_ctx_created,
251 cache_yang_ctx_invalid,
252 cache_yang_ctx_empty,
253 validation_success,
254 validation_invalid,
255 validation_malformed,
256 validation_skip,
257 messages_sent,
258 }
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, strum_macros::Display)]
263pub enum ValidationActorError {
264 #[strum(serialize = "Failed to send cache lookup command")]
265 CacheLookupSendError,
266 #[strum(serialize = "Failed to receive cache response")]
267 CacheResponseReceiveError,
268 #[strum(serialize = "Failed to send the decoded UDP-Notif packet")]
269 SendError,
270}
271
272impl std::error::Error for ValidationActorError {}
273
274#[derive(Debug, Clone, Copy)]
275enum ValidationActorCommand {
276 Shutdown,
277}
278
279struct ValidationActor {
280 max_cached_packets_per_peer: usize,
281 max_cached_packets_per_subscription: usize,
282 peer_cache: FxHashMap<IpAddr, CachedPeerSubscriptions>,
283 cmd_rx: mpsc::Receiver<ValidationActorCommand>,
284 rx: async_channel::Receiver<Arc<UdpNotifRequest>>,
285 tx: async_channel::Sender<(Option<ContentId>, SubscriptionInfo, UdpNotifPacketDecoded)>,
286 cache_cmd_tx: async_channel::Sender<CacheLookupCommand>,
287 cache_tx: async_channel::Sender<CacheResponse>,
288 cache_rx: async_channel::Receiver<CacheResponse>,
289 stats: ValidationStats,
290}
291
292impl ValidationActor {
293 fn check_subscription_new(&mut self, peer: SocketAddr, subscription_info: &SubscriptionInfo) {
299 if let Some(cached_peer_subscriptions) = self.peer_cache.get_mut(&peer.ip()) {
300 let is_different = cached_peer_subscriptions
301 .subscriptions
302 .get(&subscription_info.id())
303 .map(|x| x.subscription_info != *subscription_info)
304 .unwrap_or(true);
305 if is_different {
306 trace!(
307 peer=%peer,
308 subscription_id=subscription_info.id(),
309 router_content_id=subscription_info.content_id(),
310 target=%subscription_info.target(),
311 "Subscription changed, removing from cache to allow a new fetch schemas request"
312 );
313 cached_peer_subscriptions
314 .subscriptions
315 .remove(&subscription_info.id());
316 }
317 if cached_peer_subscriptions.subscriptions.is_empty() {
319 self.peer_cache.remove(&peer.ip());
320 }
321 }
322 }
323
324 fn get_subscription_info(
331 &mut self,
332 peer: SocketAddr,
333 collector: SocketAddr,
334 interface: Option<String>,
335 decoded: &UdpNotifPacketDecoded,
336 ) -> Option<(SubscriptionInfo, Option<Option<String>>)> {
337 let message_id = decoded.message_id();
338 let publisher_id = decoded.publisher_id();
339 let notif_contents = if let Some(notif) = decoded.payload().notification_contents() {
340 notif
341 } else {
342 warn!(
343 peer=%peer,
344 message_id,
345 publisher_id,
346 "Received UDP-Notif payload without a notifications content, dropping packet"
347 );
348 return None;
349 };
350
351 let subscription_info = if let NotificationVariant::SubscriptionStarted(
352 subscription_started,
353 )
354 | NotificationVariant::SubscriptionModified(
355 subscription_started,
356 ) = notif_contents
357 {
358 let subscription_info = if let Some(subscription_info) = self.build_subscription_info(
359 peer,
360 collector,
361 interface,
362 message_id,
363 publisher_id,
364 subscription_started,
365 ) {
366 subscription_info
367 } else {
368 warn!(
369 peer=%peer,
370 message_id,
371 publisher_id,
372 notifification_type=%notif_contents.notification_type(),
373 "Received UDP-Notif of subscription started/modified payload without subscription info, dropping packet"
374 );
375 return None;
376 };
377 self.check_subscription_new(peer, &subscription_info);
378 Some(subscription_info)
379 } else {
380 self.peer_cache.get(&peer.ip()).and_then(
381 |cached_peer_subscriptions: &CachedPeerSubscriptions| {
382 cached_peer_subscriptions
383 .subscriptions
384 .get(¬if_contents.subscription_id())
385 .map(|x| x.subscription_info.clone())
386 },
387 )
388 };
389 if let Some(subscription_info) = subscription_info {
390 let cached_content_id = self
391 .peer_cache
392 .get(&peer.ip())
393 .and_then(|cached_peer_subscriptions: &CachedPeerSubscriptions| {
394 cached_peer_subscriptions
395 .subscriptions
396 .get(¬if_contents.subscription_id())
397 })
398 .filter(|x| x.subscription_info == subscription_info)
399 .map(|x| x.cached_content_id.clone());
400
401 Some((subscription_info, cached_content_id))
402 } else {
403 None
404 }
405 }
406
407 fn cache_packet(
408 &mut self,
409 subscription_info: SubscriptionInfo,
410 message: Arc<UdpNotifRequest>,
411 ) -> bool {
412 let peer = message.peer_address();
413 let packet = message.packet();
414 let message_id = packet.message_id();
415 let publisher_id = packet.publisher_id();
416 let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
417 let subscription_id = subscription_info.id();
418 let peer_cache = self.peer_cache.entry(peer.ip()).or_default();
419 let total_cached_packets = peer_cache
420 .subscriptions
421 .values()
422 .map(|x| x.cached_packets.len())
423 .sum::<usize>();
424 let subscription_cache =
425 peer_cache
426 .subscriptions
427 .entry(subscription_id)
428 .or_insert(CachedSubscription {
429 cached_content_id: None,
430 subscription_info: subscription_info.clone(),
431 yang_ctx: None,
432 cached_packets: Vec::new(),
433 });
434 if subscription_cache.cached_packets.len() > self.max_cached_packets_per_subscription {
435 warn!(
437 peer=%peer,
438 message_id,
439 publisher_id,
440 subscription_id,
441 subscription_target=%subscription_info.target(),
442 router_content_id=subscription_info.content_id(),
443 "Cache full for subscription, dropping new packet"
444 );
445 peer_tags.push(opentelemetry::KeyValue::new(
446 OTL_CACHE_DROP_REASON_KEY,
447 OTL_CACHE_DROP_REASON_SUBSCRIPTION_CACHE_FULL,
448 ));
449 self.stats.cache_drop.add(1, &peer_tags);
450 return false;
451 }
452 if total_cached_packets > self.max_cached_packets_per_peer {
453 warn!(
454 peer=%peer,
455 message_id,
456 publisher_id,
457 subscription_id,
458 subscription_target=%subscription_info.target(),
459 router_content_id=subscription_info.content_id(),
460 "Cache full for peer, dropping new packet");
461 peer_tags.push(opentelemetry::KeyValue::new(
462 OTL_CACHE_DROP_REASON_KEY,
463 OTL_CACHE_DROP_REASON_PEER_CACHE_FULL,
464 ));
465 self.stats.cache_drop.add(1, &peer_tags);
466 return false;
467 }
468 trace!(
469 peer=%peer,
470 message_id,
471 publisher_id,
472 subscription_id,
473 subscription_target=%subscription_info.target(),
474 router_content_id=subscription_info.content_id(),
475 "Cached UDP-Notif packet"
476 );
477 self.stats
478 .cached_packets
479 .record(total_cached_packets as u64, &peer_tags);
480 subscription_cache.cached_packets.push(message);
481 true
482 }
483
484 fn peer_tags_from_packet(
485 peer: SocketAddr,
486 packet: &UdpNotifPacket,
487 ) -> Vec<opentelemetry::KeyValue> {
488 let publisher_id = packet.publisher_id();
489 Vec::from([
490 opentelemetry::KeyValue::new("network.peer.address", format!("{}", peer.ip())),
491 opentelemetry::KeyValue::new(
492 "network.peer.port",
493 opentelemetry::Value::I64(peer.port().into()),
494 ),
495 opentelemetry::KeyValue::new(
496 OTL_UDP_NOTIF_PUBLISHER_ID_KEY,
497 opentelemetry::Value::I64(publisher_id.into()),
498 ),
499 ])
500 }
501
502 fn extend_peer_targs_with_subscription_info(
503 subscription_info: &SubscriptionInfo,
504 peer_tags: &mut Vec<opentelemetry::KeyValue>,
505 ) {
506 peer_tags.push(opentelemetry::KeyValue::new(
507 OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
508 opentelemetry::Value::I64(subscription_info.id().into()),
509 ));
510 peer_tags.push(opentelemetry::KeyValue::new(
511 OTL_YANG_PUSH_SUBSCRIPTION_TARGET_KEY,
512 format!("{}", subscription_info.target()),
513 ));
514 peer_tags.push(opentelemetry::KeyValue::new(
515 OTL_YANG_PUSH_SUBSCRIPTION_ROUTER_CONTENT_ID_KEY,
516 subscription_info.content_id().to_string(),
517 ));
518 }
519
520 fn decode_message(
521 &mut self,
522 peer: SocketAddr,
523 packet: &UdpNotifPacket,
524 ) -> Result<UdpNotifPacketDecoded, ()> {
525 let message_id = packet.message_id();
526 let publisher_id = packet.publisher_id();
527 let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
528
529 match UdpNotifPacketDecoded::try_from(packet) {
531 Ok(decoded) => {
532 let notif_contents = decoded.payload().notification_contents();
533 if let Some(notif_contents) = notif_contents {
534 peer_tags.push(opentelemetry::KeyValue::new(
535 OTL_YANG_PUSH_SUBSCRIPTION_ID_KEY,
536 opentelemetry::Value::I64(notif_contents.subscription_id().into()),
537 ));
538 }
539 if tracing::enabled!(tracing::Level::TRACE) {
540 let notification_type = decoded
541 .notification_type()
542 .map(|x| x.to_string())
543 .unwrap_or("UNKNOWN".to_string());
544 trace!(
545 peer=%peer,
546 message_id,
547 publisher_id,
548 notification_type,
549 "Decoded UDP-Notif payload, starting the validation step"
550 );
551 }
552 self.stats.messages_received.add(1, &peer_tags);
553 Ok(decoded)
554 }
555 Err(err) => {
556 warn!(
557 peer=%peer,
558 message_id,
559 publisher_id,
560 error=%err,
561 "Failed to decode UDP-Notif payload, dropping packet"
562 );
563 peer_tags.push(opentelemetry::KeyValue::new(
564 OTL_YANG_PUSH_DECODE_ERROR_ID_KEY,
565 format!("{err}"),
566 ));
567 self.stats.messages_received.add(1, &peer_tags);
568 self.stats.messages_decoding_fail.add(1, &peer_tags);
569 Err(())
570 }
571 }
572 }
573
574 async fn process_udp_notif_msg(
575 &mut self,
576 message: Arc<UdpNotifRequest>,
577 ) -> Result<(), ValidationActorError> {
578 let peer = message.peer_address();
579 let packet = message.packet();
580 let decoded = match self.decode_message(peer, packet) {
581 Ok(decoded) => decoded,
582 Err(_) => return Ok(()),
585 };
586 let notification_type = decoded
587 .notification_type()
588 .map(|x| x.to_string())
589 .unwrap_or("UNKNOWN".to_string());
590 let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
591 let message_id = decoded.message_id();
592 let publisher_id = decoded.publisher_id();
593 let is_legacy = matches!(decoded.payload(), UdpNotifPayload::NotificationLegacy(_));
594 let extract_sub_info = self
595 .extract_subscription_info(Arc::clone(&message), peer, &decoded)
596 .await?;
597 let subscription_info = if let Some(subscription_info) = extract_sub_info {
598 subscription_info
599 } else {
600 return Ok(());
601 };
602 Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut peer_tags);
603 let peer_cache = self.peer_cache.entry(peer.ip()).or_default();
604 let subscription_cache = peer_cache
605 .subscriptions
606 .entry(subscription_info.id())
607 .or_insert(CachedSubscription {
608 cached_content_id: None,
609 subscription_info: subscription_info.clone(),
610 yang_ctx: None,
611 cached_packets: Vec::new(),
612 });
613 let cached_content_id = if let Some(cached_content_id) =
614 subscription_cache.cached_content_id.clone()
615 && let Some(yang_ctx) = subscription_cache.yang_ctx.as_ref()
616 && !subscription_info.is_empty()
617 {
618 let validation_result = Self::validate_message(
619 packet,
620 peer,
621 &subscription_info,
622 cached_content_id.clone(),
623 ¬ification_type,
624 yang_ctx,
625 is_legacy,
626 );
627 if validation_result.is_err() {
629 self.stats.validation_invalid.add(1, &peer_tags);
630 return Ok(());
631 }
632 Some(cached_content_id)
633 } else {
634 trace!(
635 peer=%peer,
636 message_id,
637 publisher_id,
638 subscription_id=subscription_info.id(),
639 router_content_id=subscription_info.content_id(),
640 target=%subscription_info.target(),
641 notification_type,
642 "No YANG schemas found, skipping validation step",
643 );
644 self.stats.validation_skip.add(1, &peer_tags);
645 None
646 };
647
648 self.tx
649 .send((
650 cached_content_id.clone(),
651 subscription_info.clone(),
652 decoded,
653 ))
654 .await
655 .map_err(|_| {
656 warn!(
657 peer=%peer,
658 message_id,
659 publisher_id,
660 subscription_id=subscription_info.id(),
661 router_content_id=subscription_info.content_id(),
662 target=%subscription_info.target(),
663 cached_content_id=cached_content_id.clone().unwrap_or_default(),
664 notification_type,
665 "Failed to send UDP-Notif message for the next actor to process"
666 );
667 ValidationActorError::SendError
668 })?;
669 self.stats.messages_sent.add(1, &peer_tags);
670 trace!(
671 peer=%peer,
672 message_id,
673 publisher_id,
674 subscription_id=subscription_info.id(),
675 router_content_id=subscription_info.content_id(),
676 target=%subscription_info.target(),
677 cached_content_id=cached_content_id.unwrap_or_default(),
678 notification_type,
679 "Successfully send UDP-Notif message for the next actor to process"
680 );
681 Ok(())
682 }
683
684 fn validate_message(
685 packet: &UdpNotifPacket,
686 peer: SocketAddr,
687 subscription_info: &SubscriptionInfo,
688 cached_content_id: ContentId,
689 notification_type: &String,
690 yang_ctx: &yang5::context::Context,
691 is_legacy: bool,
692 ) -> Result<(), yang5::Error> {
693 let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
694 Self::extend_peer_targs_with_subscription_info(subscription_info, &mut peer_tags);
695 let message_id = packet.message_id();
696 let publisher_id = packet.publisher_id();
697
698 if !is_legacy {
699 let validation_result = yang5::data::DataTree::parse_string(
700 yang_ctx,
701 packet.payload(),
702 DataFormat::JSON,
703 DataParserFlags::STRICT,
704 DataValidationFlags::PRESENT,
705 );
706 if let Err(err) = validation_result {
707 let v = packet.payload().clone();
708 let packet_payload =
709 str::from_utf8(v.as_ref()).unwrap_or("unserializable packet payload");
710 warn!(
711 peer=%peer,
712 message_id,
713 publisher_id,
714 subscription_id=subscription_info.id(),
715 router_content_id=subscription_info.content_id(),
716 target=%subscription_info.target(),
717 cached_content_id,
718 notification_type,
719 error=%err,
720 packet=packet_payload,
721 "Failed to validate UDP-Notif payload using draft-ietf-netconf-notif-envelope, dropping packet"
722 );
723 return Err(err);
724 }
725 trace!(
726 peer=%peer,
727 message_id,
728 publisher_id,
729 subscription_id=subscription_info.id(),
730 router_content_id=subscription_info.content_id(),
731 target=%subscription_info.target(),
732 notification_type,
733 cached_content_id,
734 "Successfully validated YANG-Push message using draft-ietf-netconf-notif-envelope",
735 );
736 Ok(())
737 } else {
738 let validation_result = yang5::data::DataTree::parse_op_string(
739 yang_ctx,
740 packet.payload(),
741 DataFormat::JSON,
742 DataParserFlags::STRICT,
743 DataOperation::NotificationYang,
744 );
745 if let Err(err) = validation_result {
746 warn!(
747 peer=%peer,
748 subscription_id=subscription_info.id(),
749 router_content_id=subscription_info.content_id(),
750 target=%subscription_info.target(),
751 cached_content_id,
752 notification_type,
753 error=%err, "Failed to validate legacy UDP-Notif payload, dropping packet");
754 return Err(err);
755 }
756 trace!(
757 peer=%peer,
758 message_id,
759 publisher_id,
760 subscription_id=subscription_info.id(),
761 router_content_id=subscription_info.content_id(),
762 target=%subscription_info.target(),
763 notification_type,
764 cached_content_id,
765 "Successfully validated YANG-Push message using legacy UDP-Notif payload",
766 );
767 Ok(())
768 }
769 }
770
771 async fn extract_subscription_info(
774 &mut self,
775 message: Arc<UdpNotifRequest>,
776 peer: SocketAddr,
777 decoded: &UdpNotifPacketDecoded,
778 ) -> Result<Option<SubscriptionInfo>, ValidationActorError> {
779 let collector = message.collector_address();
780 let interface = message.collector_interface();
781 let packet = message.packet();
782 let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
783 let message_id = decoded.message_id();
784 let publisher_id = decoded.publisher_id();
785 let notification_type = decoded
786 .notification_type()
787 .map(|x| x.to_string())
788 .unwrap_or("UNKNOWN".to_string());
789
790 match self.get_subscription_info(peer, collector, interface.map(String::from), decoded) {
791 Some((subscription_info, cached_content_id)) => {
792 Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut peer_tags);
793 if cached_content_id.is_some() {
794 return Ok(Some(subscription_info));
795 }
796 debug!(
797 peer=%peer,
798 message_id,
799 publisher_id,
800 subscription_id=subscription_info.id(),
801 router_content_id=subscription_info.content_id(),
802 subscription_target=%subscription_info.target(),
803 notification_type,
804 "Received new subscription sending lookup by subscription info request to the cache"
805 );
806 self.stats
807 .cache_request_by_subscription_info
808 .add(1, &peer_tags);
809 self.cache_cmd_tx
810 .send(CacheLookupCommand::LookupBySubscriptionInfo(
811 subscription_info.clone(),
812 self.cache_tx.clone(),
813 ))
814 .await
815 .map_err(|error| {
816 warn!(
817 message_id,
818 publisher_id,
819 subscription_id=subscription_info.id(),
820 router_content_id=subscription_info.content_id(),
821 subscription_target=%subscription_info.target(),
822 notification_type,
823 error=%error,
824 "Error sending lookup by subscription info request to the cache"
825 );
826 ValidationActorError::CacheLookupSendError
827 })?;
828 self.cache_packet(subscription_info.clone(), message);
829 Ok(None)
830 }
831 None => {
832 let subscription_id = decoded
833 .payload()
834 .notification_contents()
835 .map(|x| x.subscription_id());
836 if let Some(subscription_id) = subscription_id {
837 debug!(
838 peer=%peer,
839 message_id,
840 publisher_id,
841 subscription_id,
842 notification_type,
843 "Received UDP-Notif packet without subscription info, \
844 caching the packet and looking up subscription info in cache");
845 self.stats
846 .cache_request_by_subscription_info
847 .add(1, &peer_tags);
848 let subscription_info = SubscriptionInfo::new_empty(
849 collector,
850 interface.map(String::from),
851 peer,
852 subscription_id,
853 );
854 self.cache_cmd_tx
855 .send(CacheLookupCommand::LookupBySubscriptionId {
856 collector,
857 interface: interface.map(String::from),
858 peer,
859 subscription_id,
860 tx: self.cache_tx.clone(),
861 })
862 .await
863 .map_err(|_| ValidationActorError::CacheLookupSendError)?;
864 self.cache_packet(subscription_info.clone(), message);
865 return Ok(None);
866 }
867 warn!(
868 peer=%peer,
869 message_id,
870 publisher_id,
871 notification_type,
872 "Received UDP-Notif packet without subscription info nor subscription ID, dropping packet"
873 );
874 self.stats.validation_invalid.add(1, &peer_tags);
875 Ok(None)
876 }
877 }
878 }
879
880 async fn process_cache_response(
881 &mut self,
882 response: CacheResponse,
883 ) -> Result<(), ValidationActorError> {
884 let (cached_content_id, subscription_info, yang_lib_ref) = response.into();
885 let mut otl_tags = Vec::from([
886 opentelemetry::KeyValue::new(
887 "network.peer.address",
888 format!("{}", subscription_info.peer().ip()),
889 ),
890 opentelemetry::KeyValue::new(
891 "network.peer.port",
892 opentelemetry::Value::I64(subscription_info.peer().port().into()),
893 ),
894 ]);
895 Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut otl_tags);
896 let peer_cache = if let Some(peer_cache) =
897 self.peer_cache.get_mut(&subscription_info.peer().ip())
898 {
899 peer_cache
900 } else {
901 warn!(
902 peer=%subscription_info.peer(),
903 subscription_id=subscription_info.id(),
904 router_content_id=subscription_info.content_id(),
905 target=%subscription_info.target(),
906 cached_content_id,
907 "Received cache response for subscription from peer that is not in the cache, ignoring the response"
908 );
909 return Ok(());
910 };
911
912 let subscription_cache = if let Some(subscription_cache) =
913 peer_cache.subscriptions.get_mut(&subscription_info.id())
914 {
915 subscription_cache
916 } else {
917 warn!(
918 peer=%subscription_info.peer(),
919 subscription_id=subscription_info.id(),
920 router_content_id=subscription_info.content_id(),
921 target=%subscription_info.target(),
922 cached_content_id,
923 "Received cache response for subscription that is not in the cache, ignoring the response");
924 return Ok(());
925 };
926
927 subscription_cache.subscription_info = subscription_info.clone();
929 if let Some(yang_lib_ref) = yang_lib_ref {
930 let search_dir = yang_lib_ref.search_dir();
931 let yang_ctx_result = yang5::context::Context::new_from_yang_library_file(
932 &yang_lib_ref.yang_library_path(),
933 DataFormat::XML,
934 &search_dir.as_path(),
935 yang5::context::ContextFlags::empty(),
936 );
937 let yang_ctx = match yang_ctx_result {
938 Ok(yang_ctx) => {
939 self.stats.cache_yang_ctx_created.add(1, &otl_tags);
940 Some(yang_ctx)
941 }
942 Err(err) => {
943 self.stats.cache_yang_ctx_invalid.add(1, &otl_tags);
944 warn!(
945 peer=%subscription_info.peer(),
946 subscription_id=subscription_info.id(),
947 router_content_id=subscription_info.content_id(),
948 cached_content_id=yang_lib_ref.content_id(),
949 yang_library_path=%yang_lib_ref.yang_library_path().display(),
950 search_dir=%search_dir.display(),
951 error=%err,
952 "Failed to create YANG context, disabling YANG validation for this subscription");
953 None
954 }
955 };
956 subscription_cache.cached_content_id = cached_content_id.clone();
957 subscription_cache.yang_ctx = yang_ctx;
958 } else {
959 self.stats.cache_yang_ctx_empty.add(1, &otl_tags);
960 subscription_cache.cached_content_id = None;
961 subscription_cache.yang_ctx = None;
962 }
963 let cached_packets = std::mem::take(&mut subscription_cache.cached_packets);
964 for message in cached_packets {
965 let peer = message.peer_address();
966 let packet = message.packet();
967 let mut peer_tags = Self::peer_tags_from_packet(peer, packet);
968 Self::extend_peer_targs_with_subscription_info(&subscription_info, &mut peer_tags);
969 self.stats.cache_drain.add(1, &peer_tags);
970 trace!(
971 peer=%peer,
972 message_id=packet.message_id(),
973 publisher_id=packet.publisher_id(),
974 subscription_id=subscription_info.id(),
975 router_content_id=subscription_info.content_id(),
976 subscription_target=%subscription_info.target(),
977 cached_content_id,
978 "Packet popped out of the cache and being processed by the validation step"
979 );
980 self.process_udp_notif_msg(message).await?;
981 }
982 Ok(())
983 }
984
985 fn build_subscription_info(
986 &self,
987 peer: SocketAddr,
988 collector: SocketAddr,
989 interface: Option<String>,
990 message_id: u32,
991 publisher_id: u32,
992 sub_started: &SubscriptionStartedModified,
993 ) -> Option<SubscriptionInfo> {
994 let modules = match sub_started.module_version() {
995 Some(modules) => {
996 let mut modules = modules.to_vec();
997 modules.push(YangPushModuleVersion::new(
998 "ietf-subscribed-notifications".into(),
999 None,
1000 None,
1001 ));
1002 modules.into_boxed_slice()
1003 }
1004 None => {
1005 warn!(
1006 peer=%peer,
1007 message_id,
1008 publisher_id,
1009 subscription_id=sub_started.id(),
1010 subscription_target=%sub_started.target(),
1011 "SubscriptionStarted missing module version"
1012 );
1013 return None;
1014 }
1015 };
1016
1017 Some(SubscriptionInfo::new(
1018 collector,
1019 interface,
1020 peer,
1021 sub_started.id(),
1022 sub_started.target().clone(),
1023 sub_started.stop_time().cloned(),
1024 sub_started.transport().cloned(),
1025 sub_started.encoding().cloned(),
1026 sub_started.purpose().map(|x| x.into()),
1027 sub_started.update_trigger().cloned(),
1028 modules,
1029 sub_started
1030 .yang_library_content_id()
1031 .map(|x| x.to_string())
1032 .unwrap_or_default(),
1033 ))
1034 }
1035
1036 async fn run(mut self) -> Result<String, ValidationActorError> {
1037 info!("Starting Yang-Push validation actor");
1038 loop {
1039 tokio::select! {
1040 biased;
1041 cmd = self.cmd_rx.recv() => {
1042 return match cmd {
1043 Some(ValidationActorCommand::Shutdown) => {
1044 info!("Shutting down Yang Push validation actor");
1045 Ok("Enrichment shutdown successfully".to_string())
1046 }
1047 None => {
1048 let msg = "Yang Push validation actor terminated due to command channel closing";
1049 warn!(msg);
1050 Ok(msg.to_string())
1051 }
1052 }
1053 }
1054 msg = self.rx.recv() => {
1055 match msg {
1056 Ok(msg) => {
1057 if let Err(err) = self.process_udp_notif_msg(msg).await {
1058 let err_msg = "Yang Push validation actor UDP-Notif processing unrecoverable error, shutting down";
1059 warn!(error=%err, err_msg);
1060 return Ok(err_msg.to_string());
1061 }
1062 }
1063 Err(error) => {
1064 let err_msg = "Yang Push validation actor UDP Notif receiver channel closed unexpectedly, shutting down";
1065 warn!(error=%error, err_msg);
1066 return Ok(err_msg.to_string());
1067 }
1068 }
1069 }
1070 msg = self.cache_rx.recv() => {
1071 match msg {
1072 Ok(response) => {
1073 if let Err(err) = self.process_cache_response(response).await {
1074 let err_msg = "Yang Push validation actor cache response processing unrecoverable error, shutting down";
1075 warn!(error=%err, err_msg);
1076 return Ok(err_msg.to_string());
1077 }
1078 }
1079 Err(error) => {
1080 let err_msg = "Yang Push validation actor cache receiver channel closed unexpectedly, shutting down";
1081 warn!(error=%error, err_msg);
1082 return Ok(err_msg.to_string());
1083 }
1084 }
1085 }
1086 }
1087 }
1088 }
1089}
1090
1091#[derive(Debug, strum_macros::Display)]
1092pub enum ValidationActorHandleError {
1093 #[strum(serialize = "Failed to send command to actor")]
1094 SendErr,
1095}
1096
1097impl std::error::Error for ValidationActorHandleError {}
1098
1099#[derive(Debug, Clone)]
1100pub struct ValidationActorHandle {
1101 cmd_tx: mpsc::Sender<ValidationActorCommand>,
1102}
1103
1104impl ValidationActorHandle {
1105 pub fn new(
1106 buffer_size: usize,
1107 max_cached_packets_per_peer: usize,
1108 max_cached_packets_per_subscription: usize,
1109 rx: async_channel::Receiver<Arc<UdpNotifRequest>>,
1110 tx: async_channel::Sender<(Option<ContentId>, SubscriptionInfo, UdpNotifPacketDecoded)>,
1111 cache_cmd_tx: async_channel::Sender<CacheLookupCommand>,
1112 stats: either::Either<opentelemetry::metrics::Meter, ValidationStats>,
1113 ) -> Result<
1114 (
1115 tokio::task::JoinHandle<Result<String, ValidationActorError>>,
1116 Self,
1117 ),
1118 ValidationActorHandleError,
1119 > {
1120 let (cmd_tx, cmd_rx) = mpsc::channel(100);
1121 let (cache_tx, cache_rx) = async_channel::bounded(buffer_size);
1122 let stats = match stats {
1123 either::Either::Left(meter) => ValidationStats::new(meter),
1124 either::Either::Right(stats) => stats,
1125 };
1126 let actor = ValidationActor {
1127 max_cached_packets_per_peer,
1128 max_cached_packets_per_subscription,
1129 peer_cache: FxHashMap::default(),
1130 cmd_rx,
1131 rx,
1132 tx,
1133 cache_cmd_tx,
1134 cache_tx,
1135 cache_rx,
1136 stats,
1137 };
1138 let handle = ValidationActorHandle { cmd_tx };
1139 let join_handle = tokio::spawn(async move { actor.run().await });
1140 Ok((join_handle, handle))
1141 }
1142
1143 pub async fn shutdown(&self) -> Result<(), ValidationActorHandleError> {
1144 self.cmd_tx
1145 .send(ValidationActorCommand::Shutdown)
1146 .await
1147 .map_err(|_| ValidationActorHandleError::SendErr)
1148 }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154 use crate::cache::actor::tests::setup_actor_with_empty_cache;
1155 use bytes::Bytes;
1156 use netgauze_udp_notif_pkt::raw::MediaType;
1157 use std::collections::HashMap;
1158 use std::time::Duration;
1159
1160 #[tokio::test]
1161 #[tracing_test::traced_test]
1162 async fn test_validation_actor_schema_fetched() {
1163 let (caching_join_handle, caching_handle, subscription_info, fetcher_count) =
1165 setup_actor_with_empty_cache();
1166 {
1167 let hits_counts = fetcher_count
1168 .lock()
1169 .expect("Failed to lock fetcher counts")
1170 .clone();
1171 assert_eq!(hits_counts.len(), 0);
1172 }
1173
1174 let (udp_notif_tx, udp_notif_rx) = async_channel::bounded(100);
1176 let (validated_tx, validated_rx) = async_channel::bounded(100);
1177
1178 let (_join_handle, handle) = ValidationActorHandle::new(
1180 100,
1181 1000,
1182 100,
1183 udp_notif_rx,
1184 validated_tx,
1185 caching_handle.request_tx(),
1186 either::Right(ValidationStats::new(opentelemetry::global::meter(
1187 "test_meter",
1188 ))),
1189 )
1190 .expect("Failed to spawn validation actor");
1191
1192 let peer = subscription_info.peer();
1194 let payload = serde_json::json!(
1195 {
1196 "ietf-yp-notification:envelope": {
1197 "event-time": "2025-09-23T14:12:16.024Z",
1198 "hostname": "ipf-zbl1327-r-daisy-48",
1199 "sequence-number": 0,
1200 "contents": {
1201 "ietf-subscribed-notifications:subscription-started": {
1202 "id": 1,
1203 "ietf-yang-push:datastore": "ietf-datastores:operational",
1204 "ietf-yang-push:datastore-xpath-filter": "/ietf-interfaces:interfaces",
1205 "transport": "ietf-udp-notif-transport:udp-notif",
1206 "encoding": "encode-json",
1207 "purpose": "test subscription",
1208 "ietf-distributed-notif:message-publisher-id": [
1209 16843789
1210 ],
1211 "ietf-yang-push-revision:module-version": [
1212 {
1213 "name": "ietf-interfaces",
1214 "revision": "2018-02-20"
1215 }
1216 ],
1217 "ietf-yang-push-revision:yang-library-content-id": "test-content-id-1",
1218 "ietf-yang-push:periodic": {
1219 "period": 6000
1220 }
1221 }
1222 }
1223 }
1224 }
1225 );
1226 let bytes = serde_json::to_vec(&payload).unwrap();
1227 let subscription_started_packet = UdpNotifPacket::new(
1228 MediaType::YangDataJson,
1229 10,
1230 1,
1231 HashMap::new(),
1232 Bytes::from(bytes),
1233 );
1234
1235 udp_notif_tx
1237 .send(Arc::new(UdpNotifRequest::new(
1238 SocketAddr::from(([127, 0, 0, 1], 10000)),
1239 None,
1240 peer,
1241 subscription_started_packet,
1242 )))
1243 .await
1244 .unwrap();
1245
1246 tokio::time::sleep(Duration::from_millis(50)).await;
1248
1249 let (content_id, sub_info, _validated) =
1251 tokio::time::timeout(Duration::from_secs(1), validated_rx.recv())
1252 .await
1253 .expect("timeout waiting for response")
1254 .unwrap();
1255 assert!(content_id.is_some());
1256 assert!(!sub_info.is_empty());
1257
1258 {
1260 let hits_counts = fetcher_count
1261 .lock()
1262 .expect("Failed to lock fetcher counts")
1263 .clone();
1264 assert_eq!(hits_counts.len(), 1);
1265 }
1266
1267 handle.shutdown().await.unwrap();
1269 caching_handle.shutdown().await.unwrap();
1270 caching_join_handle.await.unwrap().unwrap();
1271 }
1272
1273 #[tokio::test]
1274 #[tracing_test::traced_test]
1275 async fn test_validation_actor_schema_not_found() {
1276 let (caching_join_handle, caching_handle, subscription_info, fetcher_count) =
1278 setup_actor_with_empty_cache();
1279 {
1280 let hits_counts = fetcher_count
1281 .lock()
1282 .expect("Failed to lock fetcher counts")
1283 .clone();
1284 assert_eq!(hits_counts.len(), 0);
1285 }
1286
1287 let (udp_notif_tx, udp_notif_rx) = async_channel::bounded(100);
1289 let (validated_tx, validated_rx) = async_channel::bounded(100);
1290
1291 let (_join_handle, handle) = ValidationActorHandle::new(
1293 100,
1294 1000,
1295 100,
1296 udp_notif_rx,
1297 validated_tx,
1298 caching_handle.request_tx(),
1299 either::Right(ValidationStats::new(opentelemetry::global::meter(
1300 "test_meter",
1301 ))),
1302 )
1303 .expect("Failed to spawn validation actor");
1304
1305 let peer = subscription_info.peer();
1307 let payload = serde_json::json!(
1308 {
1309 "ietf-yp-notification:envelope": {
1310 "event-time": "2025-09-23T14:12:16.024Z",
1311 "hostname": "ipf-zbl1327-r-daisy-48",
1312 "sequence-number": 0,
1313 "contents": {
1314 "ietf-subscribed-notifications:subscription-started": {
1315 "id": 2,
1316 "ietf-yang-push:datastore": "ietf-datastores:operational",
1317 "ietf-yang-push:datastore-xpath-filter": "/ietf-hardware:hardware",
1318 "transport": "ietf-udp-notif-transport:udp-notif",
1319 "encoding": "encode-json",
1320 "ietf-distributed-notif:message-publisher-id": [
1321 16843789
1322 ],
1323 "ietf-yang-push-revision:module-version": [
1324 {
1325 "name": "ietf-hardware",
1326 "revision": ""
1327 }
1328 ],
1329 "ietf-yang-push-revision:yang-library-content-id": "test-content-id-1",
1330 "ietf-yang-push:periodic": {
1331 "period": 6000
1332 }
1333 }
1334 }
1335 }
1336 }
1337 );
1338 let bytes = serde_json::to_vec(&payload).unwrap();
1339 let subscription_started_packet = UdpNotifPacket::new(
1340 MediaType::YangDataJson,
1341 10,
1342 1,
1343 HashMap::new(),
1344 Bytes::from(bytes),
1345 );
1346
1347 udp_notif_tx
1349 .send(Arc::new(UdpNotifRequest::new(
1350 SocketAddr::from(([127, 0, 0, 1], 10000)),
1351 None,
1352 peer,
1353 subscription_started_packet,
1354 )))
1355 .await
1356 .unwrap();
1357
1358 tokio::time::sleep(Duration::from_millis(50)).await;
1360
1361 let (content_id, sub_info, _validated) =
1363 tokio::time::timeout(Duration::from_secs(1), validated_rx.recv())
1364 .await
1365 .expect("timeout waiting for response")
1366 .unwrap();
1367 assert!(content_id.is_none());
1368 assert!(!sub_info.is_empty());
1369
1370 {
1372 let hits_counts = fetcher_count
1373 .lock()
1374 .expect("Failed to lock fetcher counts")
1375 .clone();
1376 assert_eq!(hits_counts.len(), 1);
1377 }
1378
1379 handle.shutdown().await.unwrap();
1381 caching_handle.shutdown().await.unwrap();
1382 caching_join_handle.await.unwrap().unwrap();
1383 }
1384}