Skip to content

Configuration

DevInBlack001 edited this page Sep 18, 2026 · 12 revisions

Configuration

For the 1.x line. Running 0.5.5 or earlier? See Configuration 0.x.

There are three places settings live: the Stage 1 service unit, the dashboard, and a few JSON files in stage2/.

Stage 1 Sensor

The sensor is configured by its command line, written into the systemd unit at /etc/systemd/system/ddos-stage1.service. Edit the ExecStart line, then:

sudo systemctl daemon-reload
sudo systemctl restart ddos-stage1
Flag Default What it does
--interface <IFACE> required Ingress interface to capture on
--egress-interface <IFACE> none Second interface, enables drop rate measurement
--victim-ips <IPs> none Comma separated protected hosts
--victim-subnet <CIDR> none Protect a range instead of a list
--k <FLOAT> 2.0 Anomaly sensitivity multiplier
--alpha <FLOAT> 0.125 Rate smoothing factor
--socket <PATH> /run/ddos_stage1/stage1.sock IPC socket
--baseline-path <PATH> /var/lib/ddos_stage1/baselines.json Where the learned baseline is saved
--baseline-ttl-secs <N> 3600 Reject a saved baseline older than this
--entropy-sigma-floor <F> 0.05 Smallest entropy deviation used for the boundary
--entropy-sigma-ceiling <F> 0.15 Largest, so the boundary cannot drift too wide
--rate-sigma-floor <F> 50.0 The same floor for the rate, in pps
--max-baseline-freeze-windows <N> 400 Consecutive frozen windows before current traffic is accepted as the new baseline (1.2.0+)
--distributed-dominance <F> 0.40 Below this share from one source, traffic is too spread out to be a flood
--entropy-min-packets <N> 100 Packets a window needs before its entropy may raise an anomaly
--capture-mode <MODE> pcap pcap or kernel. See below
--bpf-object <PATH> /usr/local/lib/ddos_stage1/ddos-stage1.o Compiled programs, for the kernel backend
--max-sources <N> 65536 Source addresses tracked per window, kernel backend
--max-flows <N> 8192 Flows tracked per window, both backends
--max-protected-hosts <N> 256 Protected hosts, kernel backend
--no-filter off Capture everything, for testing only
--log-file <PATH> none Also write logs to a file

Sizing For Your Network

The kernel backend's tables have defaults suited to a modest gateway. They are not fixed limits, and raising them does not need the object rebuilt:

Flag Default Bounds
--max-sources 65536 Distinct source addresses tracked per window
--max-flows 8192 Distinct flows tracked per window
--max-protected-hosts 256 Hosts you are protecting

Raise them if any of the following applies.

You protect more than 256 hosts. This one is a hard stop rather than a degradation, so size it to your host count with room to grow.

Your gateway carries more concurrent flows than the default. This is the table that fills first, because a source reaching several destination ports takes one entry per port here and one entry in total in the source table. A flood from 2,200 addresses filled 27% of the default.

You have memory to spare and want accuracy under a wider flood. Per CPU tables hold one value per core, so the cost scales with core count. The sensor prints its own estimate at startup:

Kernel: map sizes | sources=65536 | flows=8192 | protected hosts=256 |
  locked memory ~7 MiB

Watch the occupancy while running. flows approaching its limit, or a non-zero errors, means the measurement is degrading:

Kernel: status | interface=eth0 | ingress=89810 | egress=0 |
  sources=1696 (2.6% of map) | flows=1730 | drains=20 | errors=0

Nothing is dropped when a table fills. The sensor stops tracking addresses or flows it has not seen before in that window, so existing ones keep counting and the entropy figure is computed from a partial picture. Enforcement continues.

--max-flows also sizes the libpcap backend's flow table, so the two backends stay comparable. --max-sources and --max-protected-hosts apply only to the kernel backend, since libpcap has no equivalent structure.

Raising --max-sources does not make the sensor immune to a flood that forges a new source address per packet. It buys a larger sample before the histogram truncates, which is worth having, but the underlying limitation is covered in the project's detection notes.

Choosing a Capture Backend

pcap is the default and works anywhere. Packets are copied to user space and parsed there.

kernel runs the counting inside the kernel using XDP on ingress and TC on egress, so packets are counted in the driver path rather than after a copy. Detection is identical either way: the kernel side only accumulates counts, and every decision still happens in user space.

That has been measured, not just intended. Run against the same traffic on the same hosts, with each backend learning its own baseline from scratch, the two agree on entropy to within about 1% and on ingress packet counts to within about 6%. Choose between them on whether your interface supports XDP and whether the build toolchain is available, not on expected accuracy.

The kernel maps have been checked under a flood at around 18,000 packets per second from roughly 2,200 addresses: the source table reached 3% of its capacity and the flow table 27%, with no errors recorded. See Sizing For Your Network above for raising them.

It needs the compiled object, which the installer builds and places at the default path above. It also needs a target list or subnet, since matching happens in the kernel and there is no equivalent of running without a filter.

ddos_stage1 --interface eth0 --victim-subnet 192.0.2.0/24 --capture-mode kernel

Watch the startup log to see which attachment mode the kernel gave you:

Kernel: XDP attached to 'eth0' in driver mode

Driver mode is the fast path. On an interface whose driver has no XDP support the sensor falls back to a generic mode and says so; that still works but costs more per packet.

If the object is missing or the kernel rejects the programs, the sensor exits with the reason rather than starting in a degraded state. Start with --capture-mode pcap while sorting it out.

Set RUST_LOG=debug in the unit's environment for per window detail.

Setting Values At Install Time

The installer asks whether you want to set tuning values, defaulting to no. Answer no unless you already have a reason.

Anything you do not set is left out of the service unit entirely, so the sensor's own default applies. That matters for upgrades: a unit with every value written into it is frozen at the day it was installed, and a later release that improves a default would have no effect on you.

Non interactively:

sudo bash scripts/install.sh --interface eth0 --victim-subnet 192.0.2.0/24 \
    --entropy-sigma-floor 0.08 --no-tuning-prompt

Changing them later means editing ExecStart in /etc/systemd/system/ddos-stage1.service, then systemctl daemon-reload and restarting.

Measuring The Right Values Automatically

Start with the defaults. They are what the system was tested against, and the sensor relearns your traffic baseline continuously on its own, so most installs should never touch these.

You are not expected to re tune as traffic changes. The mean and standard deviation are relearned continuously with a bounded recency window, so ordinary variation across a day or a season is absorbed without intervention. These settings exist for the case where the measured statistic goes degenerate, not as a description of your network.

When you do need to change them, do not guess. scripts/calibrate.py reads the sensor's own log and works the values out from what your network actually does:

sudo python3 scripts/calibrate.py --auto-debug

It watches for 1000 ordinary windows per protected host, roughly 8 to 17 minutes, then prints what it found and what it recommends. Nothing is written until you ask for it:

Per target, from clean windows only (k=2.00, margin=10%)

target              windows  flagged   mean pps   peak pps   rate flr   entr flr
--------------------------------------------------------------------------------
192.0.2.2              1000     1.2%      205.3      241.0       19.0     0.0550
192.0.2.3              1000     0.4%       48.1       66.7       10.4     0.0410

Recommended, covering every target:
  --rate-sigma-floor 19.0 --entropy-sigma-floor 0.055

Add --apply to save them and restart the sensor:

sudo python3 scripts/calibrate.py --auto-debug --apply

Run it on traffic you know is normal. It skips windows the sensor flagged, and the ten that follow each one, but it cannot tell a genuinely busy afternoon from a slow attack. It warns when more than a quarter of the sample was flagged, which means the sample is not peacetime and the numbers should not be trusted.

If it sits without producing anything, it will now tell you why: either a host is still warming up, and it names which and how far along, or nothing is being logged at debug level at all. --auto-debug checks after its restart that the log level actually reached the service rather than discovering the problem when the collection times out.

Do not put the gateway in the target set. See the warning below about a wide spread between hosts, which is usually this.

Option What it does
--auto-debug Turns on the detailed logging it reads, then turns it back off
--apply Saves the values and restarts the sensor
--since -6h Uses journal history instead of waiting for new windows
--windows <N> Sample size per host, default 1000
--clear-baseline Also wipes the learned baseline so it relearns
--reset Removes the calibration and returns to the defaults

Applied values live in /etc/ddos_stage1/tuning.env, read by the service unit. They override whatever was chosen at install time. Deleting the file and restarting gives those back, and --reset does the same thing for you.

Calibration is a snapshot, not a subscription. Re run it after a change in what your network carries, not on a schedule.

Choose Targets Before Calibrating

Protect the services, not the machine in front of them. A gateway carries its own management traffic, from many sources, at a volume and variability that has nothing in common with the hosts behind it. Its address usually sits in the same range, so --victim-subnet sweeps it in. --victim-ips does not.

# Sweeps in the gateway if its address is in this range
--victim-subnet 192.0.2.0/24

# Names only the services
--victim-ips 192.0.2.10,192.0.2.11,192.0.2.12

Including a gateway costs you twice. Its spread sets the floors for everyone, so every real target is measured against a figure taken from infrastructure. And because those floors fit it badly, it flags continuously, which means the enforcement tiers start throttling whatever talks to it. On a gateway that is the operators.

The symptom is a host flagging a large share of its windows on rate while entropy stays near maximum and source concentration stays low. That combination is well distributed legitimate traffic, not an attack. In one case, removing a single such host from a four target set took the other three from about a fifth of their windows flagged to none.

If the gateway itself genuinely needs protecting, it needs its own baseline, which means its own sensor.

If --victim-ips is present, --victim-subnet is ignored, so there is no need to remove it. But if you remove the subnet without adding a list, the sensor has no configured targets at all: the kernel backend refuses to start, and the libpcap backend falls back to tracking every destination it sees. Check the startup line to confirm what it resolved:

journalctl -u ddos-stage1 --no-pager -o cat | grep 'thread started'

It prints targets=Some(List([...])), targets=Some(Subnet { ... }), or targets=None.

Reading The Values Yourself

If you would rather see the raw measurements, raise the log level for long enough to pass warm-up and settle. RUST_LOG is an environment variable, not a flag, so it goes before the binary, and sudo clears the environment unless you route it through env:

sudo env RUST_LOG=info,ddos_stage1::analysis=debug \
  ddos_stage1 --interface eth0 --victim-ips 192.0.2.10 --log-file /tmp/run.log

Naming the analysis module rather than using a bare debug keeps the output to the lines below instead of every dependency's own. --log-file matters when running by hand, because output goes to your terminal rather than the journal.

Every window prints a line like:

Window #648[victim=192.0.2.2]: r=205.29 pps | h=0.9892 bits |
  μ_r=150.34 σ_r=39.46 (active=50.00) | μ_h=0.9236 σ_h=0.1096 (active=0.1096)

Read four things out of a few minutes of that:

What to note Why
The range h spans Sets the entropy sigma floor
The range r spans Sets the rate sigma floor
active= versus the raw sigma Tells you whether a bound is binding
Typical packets per window Sets the entropy minimum

active= is the one people miss. It shows the value actually in force after the floor and ceiling are applied. When it differs from the raw sigma next to it, a bound is doing the work rather than the measurement. That is fine when the measurement is degenerate, and a problem when it is not.

Then:

  • Entropy sigma floor. If h ranges across roughly 0.85 to 0.99 on normal traffic, that is a spread of about 0.14. Set the floor to something like a third of the observed spread, so mean - k * sigma sits below the lowest ordinary reading rather than through the middle of it.
  • Rate sigma floor. Same idea in pps. It exists so a near idle host does not get an absurdly low bar; if your quietest protected host normally sees a few pps, a floor of 50 means ten pps cannot look like a flood.
  • Entropy minimum packets. Note how many packets a single ordinary client sends in one window, and set this well above it.

Confirm afterwards. A correct setting shows up as ordinary windows no longer being flagged, while the anomaly lines that remain have a wide margin between the measured value and the boundary. A flagged window whose value sits a thousandth below its boundary is noise. One at h=0.0007 against a boundary of 0.94 is a real event.

Which direction is dangerous. Too sensitive is loud and self correcting: the journal fills with anomalies and you notice within minutes. Too insensitive is silent, and you find out during an incident. When unsure, err toward more sensitive and let the enforcement tiers below absorb the noise, since a rate limit is reversible and a missed flood is not.

The sensor prints every tuning value in force at startup and warns about settings that would stop detection working. Check that line after any change:

Analysis: tuning | entropy sigma 0.05..0.15 | rate sigma floor 50 |
  entropy min packets 100 | distributed dominance 0.4 | emergency 10σ |
  cooldown 10 windows

The k Multiplier

This is the main sensitivity control. The sensor flags a window when the rate exceeds mean + k * sigma, or entropy falls below mean - k * sigma.

A smaller k puts the boundary closer to the average, so more windows are flagged. A larger k demands a bigger deviation before anything fires.

Raise it if you are seeing anomalies on traffic you know is normal. Lower it if attacks are getting through. Change it in steps of half a point and watch the journal before changing it again.

The Entropy Sigma Floor

Reach for this when normal traffic is being flagged and the journal shows the measured entropy barely below its boundary:

ANOMALY window 34 | flags=0x02 | h=0.9533 (boundary=0.9579) | dom_ratio=0.200

A margin that small, with dominance low, means traffic is well spread and the boundary has collapsed onto the mean. That happens when the sensor learned its baseline during a period of unusually uniform traffic: the standard deviation comes out near zero, so roughly half of ordinary windows fall below their own mean and get flagged.

Raising --entropy-sigma-floor widens the gap. Compare the entropy figures in your own journal over a quiet period: if they range across 0.85 to 0.99, the floor needs to be around a third of that spread rather than the default.

--distributed-dominance is the companion. A window flagged only on entropy, at a normal rate, with dominance below this value is treated as ordinary traffic and still updates the baseline. Without that, a boundary sitting too close to the mean freezes the baseline permanently and the false positives sustain themselves.

Stage 2 has its own copy of this idea in dominant_ip_ratio_block_threshold. They are separate processes, so changing one does not change the other.

Quiet Windows Reading As Floods

A different symptom with a different fix. The journal shows total concentration at a low rate:

ANOMALY window 14 | flags=0x02 | r=31.4 | h=0.0000 | dom_ratio=1.000

Entropy of zero with dominance of one means every packet in that window came from a single source. That is genuinely true and usually meaningless: it happens when one client sent a short burst while the others were idle. There was only one participant, so of course it was concentrated.

Raise --entropy-min-packets. Concentration only carries information when there were enough packets for it to be surprising. Size it well above the number of packets a single ordinary client sends in one window on your network.

Leaving it too low costs more than the false alarm: these windows also freeze the baseline, because dominance above --distributed-dominance means the clean window exception does not apply, so the standard deviation stops learning while they keep firing.

Training Mode

To capture labelled data for retraining the classifier:

--train-csv /path/to/output.csv --label 0

Labels are 0 for normal, 1 for flash crowd, 2 for DDoS. In this mode every post warm up window is written to the CSV, not just the anomalous ones. Run at least two independent sessions per traffic class (kill and restart the sensor between them, a single process flipping labels shares one baseline draw across all of them), then train with bash scripts/train.sh, which prompts for the CSV and trains the RandomForest, the Isolation Forest, or both (stage2/train.py and stage2/train_isolation_forest.py directly, if you want to run one without the prompts).

Traffic generator timing matters as much as the labels. A load tool that paces every request on a fixed interval, or a flood tool run unpaced, produces almost no window to window variation in the smoothed rate, which teaches the model "this traffic is mechanically regular" instead of the class signature you actually want captured. Randomise wait times and vary the active source count over the session.

Automatic Labeling

Since 1.3.0. Manual labeling above needs a person running a deliberate capture session. Most traffic captured passively during normal operation, Isolation Forest flagged windows, windows seen before any RandomForest exists on a fresh deployment, and, since 1.5.0, windows the RandomForest confidently calls DDoS, has no path to labeled training data otherwise. stage2/auto_label.py, run periodically by ddos-stage2-auto-label.timer, closes that gap without trusting a model's opinion of itself.

A captured row is only auto-labeled once:

  • The RandomForest and an independently trained second model (HistGradientBoostingClassifier) agree on the same class.
  • Both clear a confidence threshold on it.
  • Both were trained after the row was captured. A model that has not changed since a row was captured cannot confirm its own blind spot, even with a second opinion agreeing.
  • The row is not a degenerate, all-zero-traffic window. A window with no measurable signal in any of its shape features is not evidence for any class, whatever a model reports.

Anything that does not clear every check stays exactly where it is, for the next run or for a person to review by hand, the same as it works today. Staged rows land in auto_labeled_capture.csv for review first. Merge them into your training CSV yourself once you are satisfied, following the same clean rule as a manual capture above, or from the dashboard's Auto Label page: a completed run with rows to review shows up there as a timestamped alert, with a matching badge on every other page's sidebar. Merge and Discard buttons there do the same thing this section describes, from a browser instead of a terminal. Merge needs TRAINING_CSV_PATH set, the same value --training-csv below sets for you; without it, Merge is disabled on the page and Discard still works.

Enable the timer and its interval at install or update time:

sudo bash scripts/update.sh --auto-label-interval 1h

A row's age also gates it, independent of model freshness, set with AUTO_LABEL_DELAY_HOURS (default 24) as an environment override on the service. A model retrained five minutes ago does not make a row captured four minutes ago trustworthy; give it time to be a genuine second look.

Keeping both models fresh. The freshness check above means a model that never changes eventually blocks every row permanently, since every future capture postdates it. --training-csv at install or update time installs a second timer that retrains the RandomForest, the Isolation Forest, and the second model together against the CSV you name, on --retrain-interval (default 7 days):

sudo bash scripts/update.sh --training-csv /path/to/training_data.csv --retrain-interval 7d

No default path is guessed. Point it at whatever CSV you already maintain by hand; the retrain job does not need or use the passively captured, unlabeled rows this whole feature exists to eventually produce, only your own reviewed and merged training data.

The same --training-csv value is also the file the dashboard's Merge button appends to, so a merge lands in the data the next retrain reads. Point it at a file you are prepared to grow. update.sh rewrites the service unit on every run and sets the path only when the flag is present, so running it without --training-csv turns Merge off until you pass the flag again.

Capture under the tuning you deploy. The Isolation Forest fits on every feature column, including the sensor's learned standard deviations (sigma_h and sigma_r), which depend on the sigma floors in /etc/ddos_stage1/tuning.env. Rows captured under different floors than the ones a model was trained on sit in a range it never saw. Run scripts/calibrate.py first and leave the floors alone for the whole capture. Do not pool rows from a gateway with an older corpus without comparing those two columns first.

How confident is confident. The threshold is AUTO_LABEL_CONFIDENCE_THRESHOLD (default 0.90). A shallow Random Forest cannot report high probabilities on some traffic shapes, so at a depth of 3 a large share of genuine DDoS windows sit just under it. If runs stage far fewer DDoS rows than the capture file suggests, see Troubleshooting.

Enforcement Tuning

These live in stage2/enforcement_config.json and are editable from the dashboard's firewall page. Anything not present in the file falls back to its default, so an older file keeps working after an update.

Setting Default Meaning
dominant_ip_ratio_block_threshold 0.40 How concentrated traffic must be on one source before that source alone justifies a block
dominant_ip_ratio_extreme_threshold 0.75 Above this, the window is classified as an attack regardless of entropy
block_rate_floor_pps 300.0 A source must exceed this to be blocked, whatever the learned baseline says
ratelimit_rate_floor_pps 50.0 The same floor for the softer rate limit tier
block_sigma_multiplier 10.0 How far above baseline a single source must sit to be blocked
block_hysteresis_windows 2 Consecutive attack windows required before a hard block
block_duration_seconds 3600 How long a block lasts
ratelimit_duration_seconds 3600 How long a rate limit lasts
ratelimit_hashlimit_pps 50 The packets per second cap enforced on rate limited sources

The two floors exist to stop a near idle host producing an absurdly low bar. If a protected host normally receives two packets per second, its learned baseline would otherwise let ten packets per second look like a flood.

block_hysteresis_windows applies only to hard blocks. Rate limiting is not gated by it, on the reasoning that throttling a legitimate source is recoverable and blocking one is not.

Changing ratelimit_hashlimit_pps rewrites the live iptables rule, since the cap is part of the rule itself and cannot be edited in place.

Enforcement Tiers

Understanding what fires when makes the settings above easier to reason about.

Tier 1, dominant source. One source is carrying most of the traffic and is sending far above baseline. Blocked outright.

Tier 2, per source escalation. Individual sources above their own threshold are blocked, after the hysteresis window count.

Tier 3, soft rate limit. Sources over the rate limit floor are throttled rather than blocked.

Tier 4, aggregate fallback. The window is clearly an attack but no individual source stands out. Everything contributing is rate limited, nothing is blocked.

The progression is deliberate. Blocking is only used where attribution is confident.

Protected Hosts

Managed from the dashboard's targets page, stored in stage2/victims.json.

Adding a host here tells Stage 2 which addresses to track and report on. It does not change the sensor's capture filter, which comes from the service unit. If you add a target that the sensor is not filtering for, no traffic to it will ever be seen. Keep the two in step.

Whitelist

Addresses on the whitelist are never blocked and never rate limited, at any tier. Stored in stage2/whitelist.json, managed from the firewall page.

Put your own management address here before you do anything else. Blocking yourself out of the gateway is the most common self inflicted problem, and recovering means console access to flush the ipset by hand.

Shared and NAT Addresses

Stored in stage2/shared_ips.json.

An address marked as shared fronts many hosts, so dropping it entirely would cut off every legitimate user behind it. When enforcement decides to block an address on this list, it rate limits instead. The attacker's share is capped and everyone else keeps working.

Mark your NAT and CGNAT egress points here. FLOD cannot detect them on its own, because from the outside a NAT gateway and a single busy host look identical.

Alerts

Configured on the dashboard's alerts page, stored in stage2/alerts_config.json with owner only permissions.

Discord needs a webhook URL from your server's channel settings.

Email uses SMTP with STARTTLS. For Gmail you need an app password, not your account password, which requires two factor authentication to be enabled on the account first.

Alerts fire on classification changes into and out of an attack state, and on hard blocks, with repeats for the same address suppressed for as long as the block lasts. Soft rate limits do not alert, since under load they would be constant.

There is a test button for each channel so you can confirm delivery without waiting for a real attack.

Alert failures are logged and swallowed. A broken webhook will never interfere with enforcement.

File Locations

Path Contents
stage2/stage2.db Accounts, incident log, metrics history
stage2/victims.json Protected hosts
stage2/whitelist.json Never enforced against
stage2/shared_ips.json NAT egress points
stage2/enforcement_config.json Tuning above
stage2/alerts_config.json Webhook and SMTP settings
/var/lib/ddos_stage1/baselines.json Learned baseline, survives restart
/etc/ddos_stage1/tuning.env Measured tuning, written by calibrate.py
/run/ddos_stage1/stage1.sock IPC socket
/etc/ddos_stage2/tls/ Certificate and key
/var/lib/flod/anomalous_capture.csv Isolation Forest flagged windows, awaiting review or auto-labeling
/var/lib/flod/pretraining_capture.csv Windows captured before any RandomForest exists on this deployment
/var/lib/flod/ddos_capture.csv Windows the RandomForest confidently calls DDoS, awaiting auto-labeling (since 1.5.0)
/var/lib/flod/auto_labeled_capture.csv Auto-labeled rows, staged for you to merge into training data

The files holding credentials or secrets are owner only. They protect against other local accounts, not against root, and both services run as root.