///|
/// End-to-end v3 suite: drives a real net-snmp agent through engine
/// discovery, then asks the same question at each security level.
///
/// docker build -t moon-snmp-e2e e2e/docker
/// docker run -d --name snmpd -p 16161:161/udp moon-snmp-e2e
/// moon run e2e/v3query -- 127.0.0.1 16161
///
/// The user names and passwords are the ones `e2e/docker/snmpd.conf`
/// provisions, so the two files have to agree.
///
/// Exits non-zero if any check fails.
///|
const DEFAULT_HOST : String = "127.0.0.1"
///|
const DEFAULT_PORT : Int = 16161
///|
/// How long to wait for one reply, in milliseconds.
const TIMEOUT_MS : Int = 2000
///|
/// Retransmissions before giving up. Discovery costs one round trip on
/// its own, so a flaky first packet would otherwise fail the whole run.
const RETRIES : Int = 2
///|
/// The user with no authentication at all.
fn noauth_user() -> @snmpv3.Security {
{
level: @snmpv3.NoAuthNoPriv,
user: b"e2e-noauth",
auth_protocol: @snmpv3.None,
auth_password: b"",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
}
///|
/// Authenticated with HMAC-MD5-96, not encrypted.
fn md5_user() -> @snmpv3.Security {
{
level: @snmpv3.AuthNoPriv,
user: b"e2e-md5",
auth_protocol: @snmpv3.Md5,
auth_password: b"md5authpass",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
}
///|
/// Authenticated with HMAC-SHA-96, not encrypted.
fn sha_user() -> @snmpv3.Security {
{
level: @snmpv3.AuthNoPriv,
user: b"e2e-sha",
auth_protocol: @snmpv3.Sha1,
auth_password: b"shaauthpass",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
}
///|
/// Authenticated with HMAC-SHA-256, not encrypted (RFC 7860).
fn sha256_user() -> @snmpv3.Security {
{
level: @snmpv3.AuthNoPriv,
user: b"e2e-sha256",
auth_protocol: @snmpv3.Sha256,
auth_password: b"sha256authpass",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
}
///|
/// Authenticated with HMAC-SHA-512 (RFC 7860): a 48-octet
/// authenticator, four times the width MD5 uses.
fn sha512_user() -> @snmpv3.Security {
{
level: @snmpv3.AuthNoPriv,
user: b"e2e-sha512",
auth_protocol: @snmpv3.Sha512,
auth_password: b"sha512authpass",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
}
///|
/// Authenticated with HMAC-SHA-256 and encrypted, which also proves the
/// privacy key is derived with the *authentication* hash (RFC 3414
/// §2.6): here a SHA-2 one.
fn sha256_priv_user() -> @snmpv3.Security {
{
level: @snmpv3.AuthPriv,
user: b"e2e-sha256priv",
auth_protocol: @snmpv3.Sha256,
auth_password: b"sha256authpass",
priv_protocol: @snmpv3.Aes128,
priv_password: b"sha256privpass",
}
}
///|
/// Authenticated and encrypted with AES-128.
fn priv_user() -> @snmpv3.Security {
{
level: @snmpv3.AuthPriv,
user: b"e2e-priv",
auth_protocol: @snmpv3.Sha1,
auth_password: b"shaauthpass",
priv_protocol: @snmpv3.Aes128,
priv_password: b"shaprivpass",
}
}
///|
fn main {
let args = @support.Args::read()
let host = args.text(2, DEFAULT_HOST)
let port = args.number(3, DEFAULT_PORT)
println("v3 suite against \{host}:\{port}\n")
let checks = @support.Checks::new()
// Opening a socket can fail, and `main` cannot raise, so the failure is
// turned into a check like any other rather than into a stack trace.
let socket = @support.open_endpoint(host) catch {
e => {
checks.bad("a socket could be opened", Repr(e).to_string())
checks.finish("v3 suite")
@support.open_endpoint(host) catch {
_ => abort("unreachable")
}
}
}
run(checks, socket, host, port) catch {
e => checks.bad("the suite ran to completion", Repr(e).to_string())
}
socket.close()
checks.finish("v3 suite")
}
///|
fn run(
checks : @support.Checks,
socket : @transport.Transport,
host : String,
port : Int,
) -> Unit raise {
let sys_name = @mib.sys_name()
// Discovery first, on its own, so a failure here is clearly a
// discovery failure rather than a get that happened to go wrong.
let probe = @session.V3Client::new(
socket,
host,
port,
noauth_user(),
TIMEOUT_MS,
RETRIES,
)
probe.discover()
let engine = probe.engine()
checks.that("discovery found an engine id", engine.id.length() > 0)
checks.that("the reported engine clock is running", engine.time >= 0)
println(" engine id \{hex(engine.id)}")
println(" engine boots \{engine.boots} time \{engine.time}")
// 1. No authentication. Worth having working: it is what discovery
// itself uses, and the easiest level to debug an agent with.
let plain = probe.get(sys_name)
checks.text(
"noAuthNoPriv reads sysName.0",
string_of(plain),
"moon-snmp-test-agent",
)
// 2. Authentication with MD5: the key is localized to the engine, and
// the request carries an authenticator over the whole message.
let md5 = @session.V3Client::new(
socket,
host,
port,
md5_user(),
TIMEOUT_MS,
RETRIES,
)
md5.discover()
checks.text(
"authNoPriv with HMAC-MD5-96 reads sysName.0",
string_of(md5.get(sys_name)),
"moon-snmp-test-agent",
)
// 3. The same with SHA-1, whose localized key is a different length —
// the two must not be mixed up.
let sha = @session.V3Client::new(
socket,
host,
port,
sha_user(),
TIMEOUT_MS,
RETRIES,
)
sha.discover()
checks.text(
"authNoPriv with HMAC-SHA-96 reads sysName.0",
string_of(sha.get(sys_name)),
"moon-snmp-test-agent",
)
// 4. Authenticated *and* encrypted. This is the one that exercises the
// AES key, the initial vector from the engine clock, and the salt —
// and, on the way back, decryption of a message whose authenticity
// is checked before it is decrypted.
let encrypted = @session.V3Client::new(
socket,
host,
port,
priv_user(),
TIMEOUT_MS,
RETRIES,
)
encrypted.discover()
checks.text(
"authPriv with AES-128 reads sysName.0",
string_of(encrypted.get(sys_name)),
"moon-snmp-test-agent",
)
// 5. A second request under the same keys: the salt must advance, or
// the keystream would repeat and the second message would be
// readable by anyone who saw the first.
let second = encrypted.get(@mib.sys_descr())
checks.that(
"a second authPriv request works with a fresh salt",
string_of(second).length() > 0,
)
// 6. Several values in one encrypted round trip, and a GETNEXT, so the
// scoped PDU is exercised with more than one shape inside it.
let many = encrypted.get_many([@mib.sys_name(), @mib.sys_location()])
checks.num("authPriv reads two values in one round trip", many.length(), 2)
checks.text(
"the second of them is sysLocation.0",
string_of(
match many.get(1) {
Some(vb) => vb
None => @pdu.VarBind::new(sys_name, @ber.BerValue::Null)
},
),
"Hackathon Lab",
)
let next = encrypted.get_next(@mib.sys_descr())
checks.text(
"authPriv GETNEXT steps to sysObjectID.0",
next.name.to_dotted(),
@mib.sys_object_id().to_dotted(),
)
// 7. A wrong password is refused by the agent, and that refusal has to
// reach the caller as a security error rather than as silence or as
// an empty answer. The agent answers with a Report counting a wrong
// digest, which is exactly what distinguishes a bad password from an
// unreachable agent.
let wrong : @snmpv3.Security = {
level: @snmpv3.AuthNoPriv,
user: b"e2e-sha",
auth_protocol: @snmpv3.Sha1,
auth_password: b"not-the-password",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
let bad = @session.V3Client::new(
socket,
host,
port,
wrong,
TIMEOUT_MS,
RETRIES,
)
bad.discover()
let outcome = try {
let _ = bad.get(sys_name)
"answered"
} catch {
@session.SnmpError::Security(e) =>
match e {
@snmpv3.Report(oid, _) => "report: \{@snmpv3.report_meaning(oid)}"
other => "security: \{Repr(other)}"
}
@session.SnmpError::NoResponse => "no response"
e => "other: \{Repr(e)}"
}
checks.that(
"a wrong password is refused as a security error",
outcome != "answered" && outcome != "no response",
)
println(" wrong password -> \{outcome}")
// 8. An unknown user is a different report from a wrong password, which
// is what lets a caller tell a typo in a password from a typo in a
// user name.
let unknown : @snmpv3.Security = {
level: @snmpv3.AuthNoPriv,
user: b"nobody-here",
auth_protocol: @snmpv3.Sha1,
auth_password: b"shaauthpass",
priv_protocol: @snmpv3.None,
priv_password: b"",
}
let stranger = @session.V3Client::new(
socket,
host,
port,
unknown,
TIMEOUT_MS,
RETRIES,
)
stranger.discover()
let stranger_outcome = try {
let _ = stranger.get(sys_name)
"answered"
} catch {
@session.SnmpError::Security(e) =>
match e {
@snmpv3.Report(oid, _) => "report: \{@snmpv3.report_meaning(oid)}"
other => "security: \{Repr(other)}"
}
@session.SnmpError::NoResponse => "no response"
e => "other: \{Repr(e)}"
}
checks.that(
"an unknown user is refused as a security error",
stranger_outcome != "answered",
)
println(" unknown user -> \{stranger_outcome}")
// 9. SET over v3. `snmpEnableAuthenTraps.0` is the writable scalar in
// MIB-II's snmp group, and it is *not* configured in snmpd.conf:
// net-snmp makes a system-group object read-only as soon as a
// config directive sets it, which is why the write is aimed here
// rather than at sysContact.
let traps = authen_traps()
let before = integer_of(encrypted.get(traps))
checks.that(
"snmpEnableAuthenTraps.0 reads as an INTEGER",
before == 1 || before == 2,
)
let flipped = if before == 1 { 2 } else { 1 }
let written = encrypted.set(traps, @ber.BerValue::Integer(flipped))
checks.num("authPriv SET returns the written varbind", written.length(), 1)
checks.that(
"the agent committed the write",
integer_of(encrypted.get(traps)) == flipped,
)
let _ = encrypted.set(traps, @ber.BerValue::Integer(before))
checks.that(
"the previous value can be restored",
integer_of(encrypted.get(traps)) == before,
)
// 10. The same write as a read-only user must be refused by the
// agent. The permission lives on the agent, not in the request,
// so a client that reported success here would be inventing it.
let mut refused = false
try {
let _ = sha.set(traps, @ber.BerValue::Integer(flipped))
} catch {
@session.SnmpError::AgentError(_, _) => refused = true
_ => ()
}
checks.that("a read-only user cannot write", refused)
// 11. WALK the system group over v3. The community suite checks the
// same subtree against net-snmp's snmpwalk in CI, so walking it
// here and comparing the two says the v3 path traverses the same
// tree rather than merely returning something.
let community = @session.Session::new(
socket,
host,
port,
@pdu.V2c,
b"public",
TIMEOUT_MS,
RETRIES,
5000,
)
let reference = community.walk(@mib.system(), 0)
let walked = encrypted.walk(@mib.system(), 0)
checks.at_least(
"authPriv walk of the system group finds its scalars",
walked.length(),
8,
)
checks.num(
"authPriv walk finds as many entries as the community walk",
walked.length(),
reference.length(),
)
checks.that(
"authPriv walk names the same OIDs in the same order",
walk_names(walked) == walk_names(reference),
)
checks.text(
"authPriv walk starts at the group's first scalar",
match walked.get(0) {
Some(e) => e.name.to_dotted()
None => ""
},
@mib.sys_descr().to_dotted(),
)
let mut all_inside = true
let mut ascending = true
let mut previous : @oid.Oid? = None
for entry in walked {
if !@mib.system().is_prefix_of(entry.name) {
all_inside = false
}
match previous {
Some(p) => if entry.name.compare(p) <= 0 { ascending = false }
None => ()
}
previous = Some(entry.name)
}
checks.that("every v3 walked entry is inside the subtree", all_inside)
checks.that("the v3 walk never repeats or goes backwards", ascending)
// The same machine-readable line the community suite prints, so a
// script can compare either count with net-snmp's.
println("v3 walk system: \{walked.length()} entries")
// 12. Liveness over v3: the same one call, with the user's keys. A
// wrong password is a security error rather than silence — the
// device is there, and saying so is more useful than calling it
// down.
let alive = encrypted.probe()
checks.that("authPriv probe finds the engine", alive.reachable)
checks.that("authPriv probe reports the agent's uptime", alive.uptime > 0)
let wrong_alive = try {
let l = bad.probe()
if l.reachable {
"reachable"
} else {
"unreachable"
}
} catch {
@session.SnmpError::Security(_) => "security error"
_ => "other"
}
checks.text(
"a wrong password is a security error, not a silent device", wrong_alive, "security error",
)
// 13. GETBULK over the same subtree has to agree with the GETNEXT
// walk entry for entry: two traversals, one reply format apart.
let bulked = encrypted.walk_bulk(@mib.system(), 10, 0)
checks.num(
"authPriv walk_bulk finds as many entries as walk",
bulked.length(),
walked.length(),
)
checks.that(
"authPriv walk_bulk returns the same OIDs in the same order",
walk_names(bulked) == walk_names(walked),
)
// 14. The SHA-2 family (RFC 7860). The authenticator is no longer
// twelve octets — 24 for SHA-256, 48 for SHA-512 — so this is also
// the check that the field width follows the protocol rather than
// a constant. Nothing here is shaped differently from the SHA-1
// case above; the agent is what makes it a real test.
let sha256 : @session.V3Client[@transport.Transport] = @session.V3Client::new(
socket,
host,
port,
sha256_user(),
TIMEOUT_MS,
RETRIES,
)
sha256.discover()
checks.text(
"authNoPriv with HMAC-SHA-256 reads sysName.0",
string_of(sha256.get(sys_name)),
"moon-snmp-test-agent",
)
let sha512 : @session.V3Client[@transport.Transport] = @session.V3Client::new(
socket,
host,
port,
sha512_user(),
TIMEOUT_MS,
RETRIES,
)
sha512.discover()
checks.text(
"authNoPriv with HMAC-SHA-512 reads sysName.0",
string_of(sha512.get(sys_name)),
"moon-snmp-test-agent",
)
let sha256_priv : @session.V3Client[@transport.Transport] = @session.V3Client::new(
socket,
host,
port,
sha256_priv_user(),
TIMEOUT_MS,
RETRIES,
)
sha256_priv.discover()
checks.text(
"authPriv with SHA-256 and AES-128 reads sysName.0",
string_of(sha256_priv.get(sys_name)),
"moon-snmp-test-agent",
)
}
///|
/// The list of OIDs of a walk, for comparing two walks.
fn walk_names(entries : Array[@session.WalkEntry]) -> Array[String] {
let out : Array[String] = []
for e in entries {
out.push(e.name.to_dotted())
}
out
}
///|
/// 1.3.6.1.2.1.11.30.0, `snmpEnableAuthenTraps.0` (RFC 1907): the one
/// writable scalar in the snmp group, and therefore the object this
/// suite writes to prove a SetRequest is committed.
fn authen_traps() -> @oid.Oid {
@oid.Oid::parse("1.3.6.1.2.1.11.30.0") catch {
_ => abort("bad OID literal")
}
}
///|
/// A varbind's value as an integer, or -1 when it is something else.
fn integer_of(vb : @pdu.VarBind) -> Int {
match vb.value {
@ber.BerValue::Integer(n) => n
_ => -1
}
}
///|
/// A varbind's value as text, for comparing against a known string.
fn string_of(vb : @pdu.VarBind) -> String {
match vb.value {
@ber.BerValue::OctetString(b) => @utf8.decode_lossy(b)
other => @mib.format_value(other)
}
}
///|
/// Lowercase hex, for printing an engine id.
fn hex(bytes : Bytes) -> String {
let digits = [
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f",
]
let out : Array[String] = []
for i in 0..> 4])
out.push(digits[b & 0xF])
}
out.join("")
}