I never found a perfect solution to this. Net::SNMP::Message (part of Net::SNMP) might allow this but doesn't seem to have a publicly defined interface, and none of the Net::SNMP interface seemed especially relevant. NSNMP is closest to the spirit of what I was looking for, but it's brittle and didn't work for my packet out of the box and if I'm going to support brittle code, it's going to be my own brittle code =).
Mon::SNMP also got close to what I was looking for, but it too was broken out of the box. It appears to be abandoned, with the last release in 2001 and the developer's last CPAN release in 2002. I didn't realize it at the time but I now think that it's broken because of a change in the interface to the Convert::BER module it uses.
Mon::SNMP got me pointed toward Convert::BER. A few thousand reads of the Convert::BER POD, the Mon::SNMP source, and RFC 1157 (esp. 4.1.6, "The Trap-PDU") later and I came up with this code as a proof of concept to do what I wanted. This is just proof of concept (for reasons I'll detail after the code) so it may not be perfect, but I thought it might provide useful reference for future Perl people working in this area, so here it is:
#!/usr/bin/perl
use Convert::BER;
use Convert::BER qw(/^(\$|BER_)/);
my $ber = Convert::BER->new();
# OID I want to add to the trap if not already present
my $snmpTrapAddress = '1.3.6.1.6.3.18.1.3';
# this would be from the incoming socket in production
my $source_ip = '10.137.54.253';
# convert the octets into chars to match SNMP standard for IPs
my $source_ip_str = join('', map { chr($_); } split(/\./, $source_ip));
# Read the binary trap data from STDIN or ARGV. Normally this would
# come from the UDP receiver
my $d = join('', <>);
# Stuff my trap data into $ber
$ber->buffer($d);
print STDERR "Original packet:\n";
$ber->dump();
# Just decode the first two fields so we can tell what version we're dealing with
$ber->decode(
SEQUENCE => [
INTEGER => \$version,
STRING => \$community,
BER => \$rest_of_trap,
],
) || die "Couldn't decode packet: ".$ber->error()."\n";
if ($version == 0) {
#print STDERR "This is a version 1 trap, proceeding\n";
# decode the PDU up to but not including the VARBINDS
$rest_of_trap->decode(
[ SEQUENCE => BER_CONTEXT | BER_CONSTRUCTOR | 0x04 ] =>
[
OBJECT_ID => \$enterprise_oid,
[ STRING => BER_APPLICATION | 0x00 ] => \$agentaddr,
INTEGER => \$generic,
INTEGER => \$specific,
[ INTEGER => BER_APPLICATION | 0x03 ] => \$timeticks,
SEQUENCE => [ BER => \$varbind_ber, ],
],
) || die "Couldn't decode packet: ".$extra->error()."\n";;
# now decode the actual VARBINDS (just the OIDs really, to decode the values
# We'd have to go to the MIBs, which I neither want nor need to do
my($r, $t_oid, $t_val, %varbinds);
while ($r = $varbind_ber->decode(
SEQUENCE => [
OBJECT_ID => \$t_oid,
ANY => \$t_val,
], ))
{
if (!$r) {
die "Couldn't decode SEQUENCE: ".$extra->error()."\n";
}
$varbinds{$t_oid} = $t_val;
}
if ($varbinds{$snmpTrapAddress} || $varbinds{"$snmpTrapAddress.0"}) {
# the original trap already had the data, just print it back out
print $d;
} else {
# snmpTrapAddress isn't present, create a new object and rebuild the packet
my $new_trap = new Convert::BER;
$new_trap->encode(
SEQUENCE => [
INTEGER => $version,
STRING => $community,
[ SEQUENCE => BER_CONTEXT | BER_CONSTRUCTOR | 0x04 ] =>
[
OBJECT_ID => $enterprise_oid,
[ STRING => BER_APPLICATION | 0x00 ] => $agentaddr,
INTEGER => $generic,
INTEGER => $specific,
[ INTEGER => BER_APPLICATION | 0x03 ] => $timeticks,
SEQUENCE => [
BER => $varbind_ber,
# this next oid/val is the only mod we should be making
SEQUENCE => [
OBJECT_ID => "$snmpTrapAddress.0",
[ STRING => BER_APPLICATION | 0x00 ] => $source_ip_str,
],
],
],
],
);
print STDERR "New packet:\n";
$new_trap->dump();
print $new_trap->buffer;
}
} else {
print STDERR "I don't know how to decode non-v1 packets yet\n";
# send back the original packet
print $d;
}
So, that's it. Here's the kicker. I took ops at their word that they weren't getting the IP of the original sender in the trap. While working through this example, I found that, at least in the example they gave me, the original IP was in the agent-addr field in the trap. After showing them this and where in the API of the tool their using this is exposed they went off to try to make the change on their end. I'm daving the code above against the time they ask me for something I actually need to muck in the packet for, but for now the above will remain non-rigorously-tested proof of concept code. Hope it helps someone someday.