Skip to content

How it works

Data flow pipeline

UDP (NetFlow v5/v9/IPFIX) ──► NetFlow listener ──┐
UDP (sFlow v5)             ──► sFlow listener   ──┴──► flow records
                                                              │
                                                   Aggregator
                                               (1 s tick, sliding window, asymmetric smoothing)
                                                              │
                                                   Escalation engine
                                              (per-victim state machine per group + rule)
                                                       │              │
                                                       │          Notifications
                                                       │       (state-change events)
                                                 Mitigation manager
                                                              │
                                               Embedded GoBGP server
                                                              │
                                                       BGP peers (customer/edge routers)

The HTTP server runs independently and serves /metrics (Prometheus) and the REST API. Manual mitigations triggered via the REST API go through the same mitigation manager and fire the same notifications as automatic ones.


Groups and rules

Every protected network is organized into a group - a named policy unit that combines:

  • A set of IP prefixes to protect (the "subnets")
  • An escalation policy (thresholds, mitigation types, hold times)
  • Optional BGP route attributes (community, NO_EXPORT, AS-PATH prepend)
  • Optional per-group notification targets

When an incoming flow's destination IP matches a prefix in a group, that victim IP is tracked and evaluated against the group's policy. A single IP can only belong to one group (longest-prefix-match wins).

Where prefixes come from

Subnets can be populated three ways, and all three can be combined within a single group:

  • Static - CIDR blocks listed directly in config under subnets:
  • IRR - flowwler queries an IRR WHOIS server (default: rr.ntt.net) to expand an AS-SET into all prefixes originated by its member ASNs. Refreshed every 12 hours.
  • NetBox - flowwler calls the NetBox IPAM REST API and fetches active prefixes filtered by tag and/or tenant. Refreshed every 12 hours.

All sources are merged at startup, at SIGHUP, and on the 12-hour background refresh cycle. The result is loaded into a radix tree for O(log n) lookup on every flow record.

Rules within a group

A group's escalation policy fires on the total traffic volume toward any victim IP in the group. Rules add a second, independent layer of evaluation that fires on a specific type of traffic:

groups:
  - name: customers
    subnets: [203.0.113.0/24]
    escalation:
      - level: 1
        condition: {bps: 500m}   # alert at 500 Mbps total
      - level: 2
        condition: {bps: 2g}     # blackhole at 2 Gbps total
        mitigation: {type: blackhole, ...}
    rules:
      - name: udp-flood
        match: {protocol: 17}   # UDP only
        escalation:
          - level: 1
            condition: {bps: 200m}    # FlowSpec at 200 Mbps UDP
            mitigation: {type: flowspec, ...}

The group-level and rule-level state machines run in parallel and independently for each victim. In the example above, a 300 Mbps UDP flood triggers the rule-level FlowSpec mitigation without touching the group-level state machine at all. If the attack grows to 2 Gbps total, the group-level blackhole activates alongside the existing FlowSpec rule - each with its own BGP path and its own hold timer.

Rules also allow match criteria (protocol, dst_ports, src_ports, icmp_types, icmp_codes, tcp_flags, fragments) to be propagated into FlowSpec NLRIs, so the announced rule precisely describes the traffic it targets. A UDP-only rule never generates a FlowSpec NLRI that matches TCP.


Escalation state machine

Each protected IP address runs an independent state machine per group (and per rule, if rules are configured):

Idle ──(threshold exceeded)──► Active ──(threshold no longer met)──► HoldDown
         starts at lowest                 one level at a time                │
         triggered level                                                      │
              ▲                                                               │
              │◄──────────────(hold timer expired)────────────────────────────┘

A few things are worth noting about how the transitions work:

  • Idle → Active always enters at the lowest triggered level, not the highest. This avoids jumping straight to a nuclear option when a lower-severity mitigation may be sufficient.
  • Escalation advances exactly one level at a time, even if multiple higher levels are simultaneously above threshold. The escalate_after timer controls the minimum dwell time at each level before ascending.
  • Active → HoldDown fires as soon as the current level's condition is no longer met - not when all levels drop below threshold. The mitigation stays active during HoldDown. Hold-down also fires when traffic drops to zero completely (no flow records in the sliding window), so the state machine never gets stuck in Active even for attacks that stop abruptly.
  • HoldDown → Active (re-activation): if traffic rises back above threshold before the hold timer expires, the engine re-enters Active immediately at the appropriate level. The mitigation remains continuously active - no BGP withdraw and re-announce occurs unless the level changes. This can cause rapid cycling when traffic oscillates around the threshold; tune thresholds with enough headroom to avoid it.
  • HoldDown → Idle withdraws the mitigation once the hold timer expires. If traffic is still elevated when the state returns to Idle, it immediately re-enters Active at the appropriate level.

Alert-only levels

A level with no mitigation: block is an alert-only level. The state machine transitions normally - Idle → Active, Active → HoldDown, HoldDown → Idle - and notifications fire on every transition. But no BGP path is ever announced, so traffic is unaffected.

Alert-only levels are useful as a warning tier below a real mitigation. For example: send a PagerDuty alert at 500 Mbps so an operator can investigate, and only trigger a blackhole automatically if the attack grows to 2 Gbps and the operator has not intervened. The alert-only level still creates an attack record and carries notification_only: true in all notifications and API responses.


Attacks and mitigations

These two concepts are related but distinct:

An attack is a detection event. It begins the moment the escalation engine crosses a threshold (Idle → Active) and ends when the state machine returns to Idle. Every attack is recorded in the history database regardless of what action, if any, is taken. An attack record captures who was targeted, when, at what peak volume, and through which escalation levels.

A mitigation is a BGP action taken in response to an attack. When an escalation level has a mitigation: block (blackhole, subnet-blackhole, or flowspec), flowwler announces a BGP path to stop or rate-limit the traffic. Not every attack produces a mitigation:

  • Alert-only attacks - levels with no mitigation: block fire notifications and record history but never touch BGP. The attack record carries notification_only: true.
  • Manual mitigations - an operator activates a BGP path directly via POST /api/v1/mitigations. There is no organic detection behind it, but an attack record is still created for auditability.

Every mitigation is linked to its attack record via attack_id. The inverse is not always true - an attack record may have no corresponding mitigation.


Rate measurement

Sliding window

Incoming flow records are accumulated in a ring buffer of fixed-width time buckets. Each tick, the current bucket is summed into the total, then the oldest bucket is discarded and a new one opened for the next second. The current rate is the sum of all buckets divided by the elapsed window depth. Rates are accurate from the first second; there is no warm-up suppression.

Sampling rate scaling

Routers do not export every packet - they sample one packet in every N and report it. Both listeners multiply bytes and packets by the sampling rate before forwarding records, so the aggregator always works with traffic estimates scaled to 100%.

The sampling rate is extracted in priority order: from per-record sampling fields, then from Options Data records (NetFlow/IPFIX), then from the default_sampling_rate config fallback.

Example: a router sampling 1-in-2000 exports a single packet record with frame_length=1500. After scaling, that record contributes 1500 × 2000 = 3 000 000 bytes and 2000 packets to the aggregator.

The current per-router sampling rate is published as flowwler_router_sampling_rate{router, protocol} in Prometheus and visible via GET /api/v1/routers.

Which fields a given exporter actually populates — packet length in particular — and how quickly its records reach flowwler after the packet crosses the router varies by protocol; see Flow input comparison.

Asymmetric smoothing

The raw rate produced by the sliding window is smoothed before threshold comparison. flowwler uses two different smoothing rates depending on which direction traffic is moving:

  • Rising (α=0.9): the smoothed value tracks real traffic closely, so a sustained attack crosses detection thresholds within seconds of onset.
  • Falling (α=0.2): the smoothed value declines slowly, preventing premature withdrawal when traffic briefly dips mid-attack and avoiding oscillation.

BPS and PPS are smoothed independently. Both α values are configurable via rate_smoothing_alpha_rise and rate_smoothing_alpha_fall in the defaults block.


FlowSpec top-N source selection

When the mitigation type is flowspec, flowwler selects the top attack sources to announce as FlowSpec NLRIs. The selection runs in one of three modes depending on how many unique source IPs are visible — plus a cross-tick override described below that can force consolidation even when the source count alone would not.

Normal path (sources ≤ max_rules)

  1. Collect all inbound flows for the victim from the current aggregation snapshot. Victim-aggregate entries and outbound tracking entries are excluded — only per-source flows count. A source only counts as currently active if it has actually sent data recently (materiality — see below); a source that stopped sending a couple of seconds ago is no longer treated as part of the live attack, even though its bytes are still folded into the windowed rate sum for longer.
  2. If the triggering rule has match criteria (protocol, dst_ports, src_ports, icmp_types, icmp_codes), restrict the candidate set to flows that satisfy those criteria. A UDP-only rule will only produce FlowSpec NLRIs for UDP sources; TCP sources for the same victim are excluded. This ensures announced NLRIs never affect traffic outside the scope of the rule.
  3. Remove any flows whose source IP appears in the whitelist for this group.
  4. Sort remaining sources by volume (BPS descending) and announce one FlowSpec NLRI per source IP, up to flowspec.max_rules (default: 10).

Consolidation path (sources > max_rules, or cross-tick spoofing confirmed)

When the number of unique source IPs exceeds max_rules — typical of randomised-source or highly distributed floods — per-source rules would be exhausted immediately and provide no meaningful coverage. Instead, flowwler searches over every match dimension it can drop — protocol, destination port, source port, packet-length range, TCP flags, ICMP type, and ICMP code — for whichever combination of kept dimensions covers the most attack traffic within max_rules groups, while never relaxing all the way down to a protocol-only (effectively blackhole-equivalent) rule:

  1. Group all candidate sources by the surviving traffic-pattern dimensions. Ports (dst_ports/src_ports) are no longer either "one exact value" or "dropped to wildcard": a kept port dimension groups by the set of distinct values observed (bounded to a small cap), so a short list of stable ports can stay in a single rule as an OR'd match instead of forcing a full drop. ICMP type/code (icmp_types/icmp_codes) are populated the same bounded-value-set way for ICMP traffic. TCP flags are handled differently, as a single value rather than a set: an operator-configured tcp_flags match always wins unless the flags actually observed for a group are a strict subset of it (then the narrower observed value is used instead); with no static config, the observed value is used directly whenever it's exactly consistent across every source in the group, and dropped otherwise. All of this is automatic — no config is needed beyond what already exists on the rule.
  2. Sum BPS and PPS across every source IP within each candidate grouping.
  3. Sort groups by total BPS (or PPS) descending and keep the top-N (up to max_rules).
  4. Announce one NLRI per group. If a group's traffic came from more than one distinct source IP, the source IP is omitted and the rule matches any source hitting that traffic pattern toward the victim — those sources were already indistinguishable from each other by anything else, so dropping the IP costs no precision. If a group traces back to exactly one source IP, that IP is kept on the rule instead, unless the traffic as a whole looks spoofed (many sources, each contributing negligible average BPS — the same heuristic behind the spoofed_sources suggestion field): in that regime, source IPs are either fabricated (rotate every packet) or legitimate third-party reflectors, so no rule ever keeps one, regardless of how the grouping comes out.

Cross-tick spoofing override: the mechanism above only kicks in once the raw source count exceeds max_rules in a single snapshot. A flood that rotates its source IPs (or source ports) but happens to show fewer unique sources than max_rules in any one tick would otherwise get precise, IP-pinned rules that are stale the instant they're announced. To catch this, flowwler tracks the set of visible source IPs across successive refreshes (every 5 seconds) of the same active FlowSpec mitigation: once at least 80% of a tick's visible sources are new (not seen the previous tick) for 3 consecutive refreshes, spoofing is considered confirmed for that mitigation's remaining lifetime, and consolidation is forced from then on regardless of how many sources are visible in any single tick.

This yields fewer, broader rules that cover the dominant attack patterns without churn, even when attackers rotate through millions of spoofed source addresses — while still pinning a rule to a specific attacker's address whenever that address is genuinely the only thing behind a given traffic pattern.

Fallback path (no source flows visible)

When no per-source flows are available at all — the attack is still ramping up, or flow data has not yet arrived — and the rule carries at least one narrowing criterion (protocol, port, TCP flags, fragments, or packet length range), a single destination-only NLRI is announced that matches all traffic to the victim on that protocol and port. If there are no narrowing criteria at all, the announcement is skipped entirely to avoid a catch-all rule equivalent to a blackhole.

Refresh behaviour

FlowSpec rules are refreshed every 5 seconds while the mitigation is active, so new top sources are picked up automatically as the attack evolves. If the NLRI key set (source IPs, ports, protocol, packet length bounds) has not changed since the last refresh, no BGP update is sent. If sources disappear entirely mid-attack, the existing specific rules are kept in place rather than being replaced with a broad catch-all.

Packet-length data behind the above (grouping, source selection) is not always native exporter telemetry — see the exporter capability table under PktLenRange for which exporters report it natively versus which fall back to a bytes/packets-derived approximation. A live FlowSpec route's own announced NLRI only ever uses native or operator-configured packet-length data, never a derived approximation on its own; a FlowSpec suggestion does show the derived value, marked pkt_len_approximate, since an operator reviews a suggestion before it's ever applied.

Materiality: what counts as "currently active"

The aggregator's sliding rate window keeps each source's bytes/packets in its windowed sum for the configured window depth (default 10 seconds), so a burst of traffic keeps contributing to the smoothed rate for a while after it stops — this is intentional, and unrelated to whether a source is still considered part of the live attack for FlowSpec source selection. Separately, every source tracks the tick it was last actually seen on; once a source has gone more than 2 ticks (roughly 2 seconds) without sending anything, it's marked stale and excluded from FlowSpec candidate selection and consolidation grouping, even though its historical bytes still count toward the windowed rate for longer. This keeps FlowSpec rules focused on sources that are still actually sending, rather than re-announcing rules for attackers that stopped seconds ago simply because the window hasn't fully decayed yet.


BGP architecture

flowwler embeds GoBGP as a library - there is no separate gobgpd process. flowwler itself is the BGP speaker. It binds to port 179 and peers directly with your customer or edge routers.

Address families

Every configured peer is offered all four address families on session establishment:

Family Used for
IPv4 Unicast Blackhole and subnet-blackhole routes for IPv4 victims
IPv6 Unicast Blackhole and subnet-blackhole routes for IPv6 victims
IPv4 FlowSpec FlowSpec NLRIs for IPv4 traffic
IPv6 FlowSpec FlowSpec NLRIs for IPv6 traffic

Peers that do not negotiate a given family simply do not receive those routes.

What gets announced

Mitigation type Prefix Next-hop
blackhole /32 (IPv4) or /128 (IPv6) Configured next_hop, typically a discard address
subnet-blackhole /24 (IPv4) or /48 (IPv6), auto-derived from victim IP Configured next_hop
flowspec One NLRI per top-N source, matching dst IP + src IP + ports + protocol + packet length Implicit discard or rate-limit action via traffic-rate extended community

BGP communities are set per mitigation:

  • blackhole.community / subnet_blackhole.community - the standard blackhole community your router expects (often 65535:666 or an RTBH community specific to your upstream)
  • flowspec.community - optional community on FlowSpec routes; accepts standard AS:VALUE (RFC 1997) or large ASN:Local1:Local2 (RFC 8092) format. Useful for upstream policy control: tag higher-severity levels with a community your upstream accepts and leave lower levels untagged so they stay local.
  • bgp.no_export / bgp.no_advertise - well-known communities to prevent the route propagating further than intended
  • bgp.asn - appended to AS_PATH for unicast routes, useful when mitigating downstream customers so the customer's ASN appears as the route origin

Optional gRPC API

When gobgp.grpc_addr is configured, the standard gobgp CLI can connect and inspect the RIB, peers, and path state directly. This is optional — flowwler exposes the same information via its own REST API at /api/v1/flowspec/rules, /api/v1/mitigations, and /api/v1/routers.


Router discard detection

When a router drops a packet locally - due to a null route, ACL, or an installed BGP blackhole - it typically reports the flow with no output interface (ifIndex 0 or 0xFFFFFFFF). flowwler can track these flows separately to measure how much traffic the router is actually absorbing.

This feature is opt-in. It is disabled by default and must be enabled explicitly:

netflow:
  treat_no_output_as_discard: true

sflow:
  treat_no_output_as_discard: true

Not all routers set the output ifIndex reliably. Check your router's documentation and test with a known blackhole before relying on discard metrics in alerting.

Threshold accounting: discarded traffic still counts toward BPS/PPS thresholds. The intent is to evaluate the full attack volume, not just the fraction that is still being forwarded. This is important after a mitigation is active: if the router is already dropping the bulk of the attack, you still want the escalation engine to see the true attack size.

Effectiveness metrics: while a non-alert-only mitigation is active, flowwler publishes:

Metric Description
flowwler_mitigation_discard_bps{group, victim_ip} Bits per second the router is currently dropping
flowwler_mitigation_discard_pps{group, victim_ip} Packets per second the router is currently dropping
flowwler_mitigation_effectiveness{group, victim_ip} discard_bps / total_bps - fraction of attack traffic absorbed (0–1)

Immediately after a BGP announcement the ratio is near 0 - the route has not been installed yet. It rises toward 1 over 30–120 seconds as the router installs the route and begins discarding. A ratio that stays near 0 after several minutes suggests the route was not accepted by the peer - check import policy and community configuration.

All three metrics are deleted (not zeroed) when the state machine returns to Idle.