diff --git a/WHATSNEW b/WHATSNEW
index 68c0405dbb7..c1cca60e545 100644
--- a/WHATSNEW
+++ b/WHATSNEW
@@ -490,6 +490,10 @@ Notable bug fixes are the following:
The HT mode factory now includes band and preamble format in its cache key. A 5 GHz request after an equivalent 2.4 GHz request therefore receives the requested band and timing. Previously, that request could receive the earlier mode. Identical complete requests still reuse one object.
+2. IPsec reports the datagrams it discards
+
+ IPsec discarded datagrams, for example by a Security Policy Database entry with the action DISCARD, without emitting packetDropped, so PacketDropVisualizer and the statistics built on that signal did not see these drops. IPsec now emits packetDropped at each discard it counts, with the reason LIFETIME_EXPIRED when an inbound packet arrives on a Security Association whose hard lifetime has ended or whose byte limit it would exceed, and OTHER_PACKET_DROP otherwise, and records the statistics packetDropLifetimeExpired and packetDropUndefined. A discarded Encapsulating Security Payload (ESP) dummy packet is still not reported. The event trajectory does not change.
+
INET-4.7 (July 2026) — feature release
--------------------------------------
diff --git a/src/inet/networklayer/ipsec/IPsec.cc b/src/inet/networklayer/ipsec/IPsec.cc
index 4b3e8fca7fd..bbaa3a1054d 100644
--- a/src/inet/networklayer/ipsec/IPsec.cc
+++ b/src/inet/networklayer/ipsec/IPsec.cc
@@ -486,6 +486,14 @@ void IPsec::sendDummyPacket(SecurityAssociation *sadEntry)
send(packet, "ipOut");
}
+void IPsec::emitPacketDropped(Packet *packet, PacketDropReason reason)
+{
+ // the hook returns DROP after this, and the network layer deletes the packet
+ PacketDropDetails details;
+ details.setReason(reason);
+ emit(packetDroppedSignal, packet, &details);
+}
+
INetfilter::IHook::Result IPsec::datagramPostRoutingHook(Packet *packet)
{
// a dummy packet is already protected
@@ -525,12 +533,14 @@ INetfilter::IHook::Result IPsec::processEgressPacket(Packet *packet, const L3Add
}
else if (spdEntry->getAction() == Action::DISCARD) {
EV_INFO << "IPsec OUT DROP rule, packet: " << egressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(outDropSignal, 1L);
outDrop++;
return INetfilter::IHook::DROP;
}
}
EV_INFO << "IPsec OUT BYPASS, no matching rule, packet: " << egressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(outDropSignal, 1L);
outDrop++;
return INetfilter::IHook::DROP;
@@ -882,6 +892,7 @@ INetfilter::IHook::Result IPsec::protectDatagram(Packet *packet, const PacketInf
// RFC 4301 section 5.1: the entry wants protection, and no SA of it covers the packet;
// with no key management to create one, the packet is discarded, not sent in clear
EV_INFO << "IPsec OUT DROP, no SA of the PROTECT entry matches, packet: " << packetInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(outDropSignal, 1L);
outDrop++;
return INetfilter::IHook::DROP;
@@ -939,6 +950,10 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
if (sadEntry != nullptr) {
if (sadEntry->getProtection() != Protection::AH) {
EV_INFO << "IPsec IN DROP AH, SA does not prescribe AH, packet: " << ingressPacketInfo.str() << std::endl;
+ // report the datagram as received
+ packet->insertAtFront(ah);
+ packet->insertAtFront(netHeader);
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -956,6 +971,11 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
// RFC 4301 section 4.4.2.1: an SA whose hard lifetime has ended accepts nothing
if (!sadEntry->isUsable(simTime(), packet->getByteLength())) {
EV_INFO << "IPsec IN DROP AH, the hard lifetime of the SA has ended, packet: " << ingressPacketInfo.str() << std::endl;
+ // report the datagram as AH processing would give it back
+ setNetworkHeaderProtocol(netHeader, (IpProtocolId)ah->getNextHeader());
+ updateNetworkHeaderLength(netHeader, packet);
+ packet->insertAtFront(netHeader);
+ emitPacketDropped(packet, LIFETIME_EXPIRED);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -972,6 +992,7 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
if (ah->getNextHeader() != IP_PROT_ESP) {
if (!matchesSelectorsOfSa(packet, sadEntry)) {
EV_INFO << "IPsec IN DROP AH, the packet does not match the selectors of its SA, packet: " << ingressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1004,6 +1025,10 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
if (sadEntry == nullptr) {
EV_INFO << "IPsec IN DROP AH, no matching rule, packet: " << ingressPacketInfo.str() << std::endl;
+ // report the datagram as received
+ packet->insertAtFront(ah);
+ packet->insertAtFront(netHeader);
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1019,6 +1044,10 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
if (sadEntry != nullptr) {
if (sadEntry->getProtection() != Protection::ESP) {
EV_INFO << "IPsec IN DROP ESP, SA does not prescribe ESP, packet: " << ingressPacketInfo.str() << std::endl;
+ // report the datagram as ESP processing received it
+ packet->insertAtFront(espHeader);
+ packet->insertAtFront(netHeader);
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1044,6 +1073,11 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
// RFC 4301 section 4.4.2.1: an SA whose hard lifetime has ended accepts nothing
if (!sadEntry->isUsable(simTime(), packet->getByteLength())) {
EV_INFO << "IPsec IN DROP ESP, the hard lifetime of the SA has ended, packet: " << ingressPacketInfo.str() << std::endl;
+ // report the datagram as ESP processing would give it back
+ setNetworkHeaderProtocol(netHeader, (IpProtocolId)espTrailer->getNextHeader());
+ updateNetworkHeaderLength(netHeader, packet);
+ packet->insertAtFront(netHeader);
+ emitPacketDropped(packet, LIFETIME_EXPIRED);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1055,6 +1089,7 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
if (!matchesSelectorsOfSa(packet, sadEntry) || (ahSadEntry != nullptr && !matchesSelectorsOfSa(packet, ahSadEntry))) {
EV_INFO << "IPsec IN DROP ESP, the packet does not match the selectors of its SA, packet: " << ingressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1086,6 +1121,10 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
if (sadEntry == nullptr) {
EV_INFO << "IPsec IN DROP ESP, no matching rule, packet: " << ingressPacketInfo.str() << std::endl;
+ // report the datagram as ESP processing received it
+ packet->insertAtFront(espHeader);
+ packet->insertAtFront(netHeader);
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inProtectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1102,12 +1141,14 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
}
else if (spdEntry->getAction() == Action::DISCARD) {
EV_INFO << "IPsec DROP rule, packet: " << ingressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inUnprotectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
}
else {
EV_INFO << "IPsec IN DROP due to wrong rule, packet: " << ingressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inUnprotectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
@@ -1116,6 +1157,7 @@ INetfilter::IHook::Result IPsec::processIngressPacket(Packet *packet)
}
EV_INFO << "IPsec IN DROP, no matching rule, packet: " << ingressPacketInfo.str() << std::endl;
+ emitPacketDropped(packet, OTHER_PACKET_DROP);
emit(inUnprotectedDropSignal, 1L);
inDrop++;
return INetfilter::IHook::DROP;
diff --git a/src/inet/networklayer/ipsec/IPsec.h b/src/inet/networklayer/ipsec/IPsec.h
index 36846c77e77..9f5f56921dd 100644
--- a/src/inet/networklayer/ipsec/IPsec.h
+++ b/src/inet/networklayer/ipsec/IPsec.h
@@ -23,6 +23,7 @@
#include "inet/common/INETDefs.h"
#include "inet/common/SimpleModule.h"
+#include "inet/common/Simsignals.h"
#include "inet/networklayer/contract/INetfilter.h"
#include "inet/common/ModuleAccess.h"
#include "inet/common/Protocol.h"
@@ -112,6 +113,7 @@ class INET_API IPsec : public SimpleModule, NetfilterBase::HookBase
virtual INetfilter::IHook::Result protectDatagram(Packet *ipv4datagram, const PacketInfo& packetInfo, SecurityPolicy *spdEntry);
virtual bool matchesSelectorsOfSa(Packet *packet, SecurityAssociation *sadEntry);
virtual void sendDummyPacket(SecurityAssociation *sadEntry);
+ virtual void emitPacketDropped(Packet *packet, PacketDropReason reason);
virtual int getIntegrityCheckValueBitLength(EncryptionAlg alg);
virtual int getInitializationVectorBitLength(EncryptionAlg alg);
diff --git a/src/inet/networklayer/ipsec/IPsec.ned b/src/inet/networklayer/ipsec/IPsec.ned
index 2c1cba693dc..ccb63addfc6 100644
--- a/src/inet/networklayer/ipsec/IPsec.ned
+++ b/src/inet/networklayer/ipsec/IPsec.ned
@@ -232,6 +232,7 @@ simple IPsec extends SimpleModule
@signal[outDrop](type=long);
@signal[inProcessDelay](type=double);
@signal[outProcessDelay](type=double);
+ @signal[packetDropped](type=inet::Packet);
@statistic[inProtectedAccept](title="Incoming Protected Packet Accepted"; record=count,vector);
@statistic[inProtectedDrop](title="Incoming Protected Packet Dropped"; record=count,vector);
@@ -242,6 +243,8 @@ simple IPsec extends SimpleModule
@statistic[outDrop](title="Outgoing Packet Dropped"; record=count,vector);
@statistic[inProcessDelay](title="Incoming Processing Delay"; record=vector,stats);
@statistic[outProcessDelay](title="Outgoing Processing Delay"; record=vector,stats);
+ @statistic[packetDropLifetimeExpired](title="packet drop: lifetime expired"; source=packetDropReasonIsLifetimeExpired(packetDropped); record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none);
+ @statistic[packetDropUndefined](title="packet drop: undefined"; source=packetDropReasonIsUndefined(packetDropped); record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none);
gates:
input ipIn; // unused: nothing is dispatched up to IPsec
diff --git a/tests/module/IPsec_packet_dropped.test b/tests/module/IPsec_packet_dropped.test
new file mode 100644
index 00000000000..a8595a25300
--- /dev/null
+++ b/tests/module/IPsec_packet_dropped.test
@@ -0,0 +1,79 @@
+%description:
+Tests that IPsec emits packetDropped when it discards a datagram, so that the
+packetDropUndefined statistic of the IPsec module counts the discards that its
+outDrop, inUnprotectedDrop and inProtectedDrop statistics count.
+
+client sends three UDP datagrams to each of three ports of server.
+- Port 2000: the client's Security Policy Database (SPD) has an OUT entry with
+ the action DISCARD, so the client's IPsec discards the datagrams.
+- Port 2001: the client's SPD has an OUT entry with the action BYPASS, and the
+ server's SPD has no entry for them, so the server's IPsec discards them on
+ arrival.
+- Port 2002: the client's SPD protects the datagrams with the Encapsulating
+ Security Payload (ESP) under Security Parameters Index (SPI) 7, and the
+ server has no Security Association (SA) and no SPD entry for them, so the server's IPsec
+ discards them after removing their network and ESP headers.
+
+%#--------------------------------------------------------------------------------------------------------------
+%file: test.ned
+import inet.networklayer.configurator.ipv4.Ipv4NetworkConfigurator;
+import inet.node.inet.StandardHost;
+import ned.DatarateChannel;
+
+network IpsecDropNetwork
+{
+ submodules:
+ configurator: Ipv4NetworkConfigurator;
+ client: StandardHost;
+ server: StandardHost;
+ connections:
+ client.pppg++ <--> { datarate = 10Mbps; delay = 0.1us; } <--> server.pppg++;
+}
+%#--------------------------------------------------------------------------------------------------------------
+%inifile: omnetpp.ini
+[General]
+ned-path = ../../../../src
+network = IpsecDropNetwork
+sim-time-limit = 5s
+record-vector-results = false
+cmdenv-express-mode = true
+
+**.hasIpsec = true
+**.client.**.spdConfig = xml("\
+ UDP2000OUTDISCARD\
+ UDP2001OUTBYPASS\
+ UDP2002OUTPROTECTESPCONFIDENTIALITYAES_CBC_1287\
+ ")
+**.server.**.spdConfig = xml("")
+
+**.client.numApps = 3
+**.client.app[*].typename = "UdpBasicApp"
+**.client.app[*].destAddresses = "server"
+**.client.app[0].destPort = 2000
+**.client.app[1].destPort = 2001
+**.client.app[2].destPort = 2002
+**.client.app[*].messageLength = 100B
+**.client.app[*].startTime = 1s
+**.client.app[*].sendInterval = 1s
+**.client.app[*].stopTime = 3.5s
+%#--------------------------------------------------------------------------------------------------------------
+%postrun-command: gawk '$1 == "scalar" && $2 ~ /\.ipv4\.ipsec$/ && ($3 == "outDrop:count" || $3 == "inUnprotectedDrop:count" || $3 == "inProtectedDrop:count" || $3 == "packetDropUndefined:count") { print $2, $3, $4 }' results/*.sca > drops.out
+%contains: drops.out
+IpsecDropNetwork.client.ipv4.ipsec outDrop:count 3
+%#--------------------------------------------------------------------------------------------------------------
+%contains: drops.out
+IpsecDropNetwork.client.ipv4.ipsec packetDropUndefined:count 3
+%#--------------------------------------------------------------------------------------------------------------
+%contains: drops.out
+IpsecDropNetwork.server.ipv4.ipsec inUnprotectedDrop:count 3
+%#--------------------------------------------------------------------------------------------------------------
+%contains: drops.out
+IpsecDropNetwork.server.ipv4.ipsec inProtectedDrop:count 3
+%#--------------------------------------------------------------------------------------------------------------
+%contains: drops.out
+IpsecDropNetwork.server.ipv4.ipsec packetDropUndefined:count 6
+%#--------------------------------------------------------------------------------------------------------------
+%postrun-command: grep "undisposed object:" test.out > test_undisposed.out || true
+%not-contains: test_undisposed.out
+undisposed object: (
+%#--------------------------------------------------------------------------------------------------------------