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_aftertimer 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 carriesnotification_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)
- 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.
- If the triggering rule has
matchcriteria (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. - Remove any flows whose source IP appears in the whitelist for this group.
- 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:
- 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-configuredtcp_flagsmatch 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. - Sum BPS and PPS across every source IP within each candidate grouping.
- Sort groups by total BPS (or PPS) descending and keep the top-N (up to
max_rules). - 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_sourcessuggestion 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 (often65535:666or an RTBH community specific to your upstream)flowspec.community- optional community on FlowSpec routes; accepts standardAS:VALUE(RFC 1997) or largeASN: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 intendedbgp.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.