stamparm/maltrail · error

one of the two matched

Error message

one of the two matched

What it means

This is a Rust `Option::expect` panic inside the TCP SYN handling path. The code matches on two lookups (`dst_port_hit` and `dst_hit`) against the trails database and asserts that at least one of them matched; if both are `None`, the invariant 'one of the two matched' is broken and the process panics. It exists to avoid re-checking an option the author believed was already proven non-empty.

Solutions

  1. Replace the `expect` with a graceful `None =>` arm that skips emit_ep (and optionally logs) instead of panicking, so a missing trail never kills the sensor thread.
  2. Verify the caller's selection logic guarantees at least one of `dst_port_hit`/`dst_hit` is `Some`; fix the upstream filter if it can admit packets with no trail match.
  3. If the trails DB can be reloaded concurrently, snapshot the lookup results (or hold the guard) so both lookups observe the same DB version.
  4. Add a regression test with a SYN to an IP absent from the trails DB to ensure it is dropped, not panicked on.

Example fix

// before
None => {
    let (info, reference) = dst_hit.expect("one of the two matched");
    (ep.dst.render().as_str().to_string(), info, reference, TRAIL::IP)
}
// after
None => match dst_hit {
    Some((info, reference)) =>
        (ep.dst.render().as_str().to_string(), info, reference, TRAIL::IP),
    None => return, // no trail matched; drop instead of panicking
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Rust has no try/catch; guard before unwrapping
if dst_port_hit.is_none() && dst_hit.is_none() { return; } // skip packet, no panic

Type guard

fn trail_present(h: &Option<(Info, Ref)>) -> bool { h.is_some() }

Try / catch

// contain panics at the packet-loop boundary
catch_unwind(AssertUnwindSafe(|| handle_syn(st, ep)))
    .unwrap_or_else(|_| log::warn!("syn handler panicked; packet dropped"));

Prevention

When it happens

Trigger: A TCP SYN packet whose destination IP and destination IP:port both fail to match any entry in the trails database reaches the `match dst_port_hit` arm with `dst_port_hit == None` and `dst_hit == None`; the `expect` then panics. Any desynchronization between the predicate that decided this packet was a 'hit' and the actual two `db()` lookups (e.g. key normalization differences, concurrent db reload) triggers it.

Common situations: Running a custom/edited trails feed that removed an IP entry between the filtering pass and the match pass; feeding packets whose addresses are rendered differently by `addr_port` vs `render`; races where the trails DB is swapped/reloaded while packets are in flight.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of stamparm/maltrail@77cfb06d76 (2026-09-13). Data as JSON: /api/errors/5f54df6aee4b0b3a. Report an issue: GitHub.

Appendix: source

Thrown at sensor/src/process.rs:728

    if previous == Some(stamp) {
        return; // skip bursts
    }

    st.metrics.trail_lookups += 1;
    let dst_hit = st.trails.db().get_ip(ep.dst).map(|v| (v.info.to_string(), v.reference.to_string()));
    let dst_port_hit =
        st.trails.db().get_ip_port(ep.dst, ep.dst_port).map(|v| (v.info.to_string(), v.reference.to_string()));

    if dst_hit.is_some() || dst_port_hit.is_some() {
        let previous_logged = st.last_logged_syn.replace(stamp);
        if previous_logged != Some(stamp) {
            // IPORT iff the matched key is the addr_port form (not the bare IP).
            let (trail, info, reference, trail_type) = match dst_port_hit {
                Some((info, reference)) => {
                    (ep.dst.addr_port(ep.dst_port).as_str().to_string(), info, reference, TRAIL::IPORT)
                }
                None => {
                    let (info, reference) = dst_hit.expect("one of the two matched");
                    (ep.dst.render().as_str().to_string(), info, reference, TRAIL::IP)
                }
            };
            let parking_off_web = info.contains("parking site") && !matches!(ep.dst_port, 80 | 443);
            if !info.contains("attacker") && !parking_off_web {
                emit_ep(st, sec, usec, ep, PROTO::TCP, trail_type, Field::Text(trail), &info, &reference);
            }
        }
    } else if !ep.dst.is_localhost() {
        let src_hit = st.trails.db().get_ip(ep.src).map(|v| (v.info.to_string(), v.reference.to_string()));
        let src_port_hit =
            st.trails.db().get_ip_port(ep.src, ep.src_port).map(|v| (v.info.to_string(), v.reference.to_string()));
        if src_hit.is_some() || src_port_hit.is_some() {
            let previous_logged = st.last_logged_syn.replace(stamp);
            if previous_logged != Some(stamp) {
                let (trail, info, reference, trail_type) = match src_port_hit {
                    Some((info, reference)) => {
                        (ep.src.addr_port(ep.src_port).as_str().to_string(), info, reference, TRAIL::IPORT)

View on GitHub (pinned to 77cfb06d76)