Getting started
Install
On Debian and its derivatives, the APT repository:
curl -fsSL https://repo.lrob.net/apt/shieldlist/shieldlist.gpg -o /usr/share/keyrings/shieldlist.gpg
echo "deb [signed-by=/usr/share/keyrings/shieldlist.gpg] https://repo.lrob.net/apt/shieldlist stable main" > /etc/apt/sources.list.d/shieldlist.list
apt update && apt install shieldlist-agent
The repository is signed with the Shieldlist key, fingerprint
B528 0C88 7016 8A94 40A9 05C4 3BB5 8113 CFAB 6DC5 (gpg --show-keys shieldlist.gpg); dev in
place of stable gets our development builds — ahead of the releases,
and expected to break. The same .deb files sit on the release page, one
per architecture, for apt install ./file.deb.
The package puts the binaries under /usr/sbin, the rule library under
/usr/share/shieldlist-agent, creates the shieldlist user the agent
runs as, installs the unit, runs shield init when there is no
configuration yet (below), and enables the service — its own and nothing
else: it does not start nftables.service (on Debian that is nft -f /etc/nftables.conf, which begins with flush ruleset and would wipe
Docker's NAT and every other firewall rule). The agent owns one table,
inet shieldlist, and touches nothing outside it.
Elsewhere, the installer:
curl -fsSLO https://git.lrob.net/Shieldlist/shieldlist-agent/raw/branch/main/scripts/install.sh
less install.sh # one file of sh — read it before running it
sh install.sh
It works out this machine's architecture, downloads that build from the
latest release, checks it against the release's SHA256SUMS, installs
shieldlist-agent and shield into /usr/local/sbin, installs the
systemd unit, and validates before starting anything. Then shield init
writes the library from the binary and the first configuration.
Re-run it to upgrade. It replaces the binaries, touches no configuration, and unbans nothing: the sets and their timeouts live in the kernel, so a restart is a gap in detection, not an opening.
Releases are not signed yet. A checksum served from the same host as the binary proves the download was not corrupted and nothing more.
Requirements
- Linux with nftables, and the
nftbinary on the path. The installer says so if it is missing; the agent will detect without it and enforce nothing. - systemd, for the unit — and for
journalctlwhen a service logs to the journal and nowhere else, which is where sshd writes on a Debian or Ubuntu without rsyslog. - Root to install. The agent itself runs as its own user, with the
capabilities to read logs it does not own and to program the kernel's
tables, and nothing else;
shield's local verbs are root's.
Building instead
git clone ssh://git@git.lrob.net:2222/Shieldlist/shieldlist-agent.git
cd shieldlist-agent && make build
install -m 0755 dist/shieldlist-agent dist/shield /usr/local/sbin/
make dist cross-compiles the whole release matrix — amd64, arm64,
armv7, riscv64 — and writes SHA256SUMS beside it. No cgo anywhere, so
there is no toolchain to install.
The first configuration
shield init — run by the package on a first install, by you after the
installer — writes two things and overwrites nothing.
The rules, under /usr/share/shieldlist-agent: the free library the
binary carries — SSH, Postfix and Dovecot, BIND, MariaDB, the console's
own, and the web rules that convict on the request itself. They live
there and not in /etc so that an upgrade can replace them, which is
how a corrected rule reaches a machine at all. Yours go in
/etc/shieldlist, and a copy under the same name replaces ours. Every
rule says in its own file what it blocks, what it applies to, what it
needs before it can detect anything and what to watch out for.
/etc/shieldlist/agent.yaml, naming the logs this machine actually
keeps, with each service's ports so a ban closes the mail and leaves the
websites alone:
state: test
logs:
- unit: ssh.service # sshd writes to the journal and nowhere else here
log: ssh
ports: [22]
- path: /var/www/vhosts/system/{target}/logs/proxy_access_ssl_log # one log per site: {target} names the site
log: http
ports: [80, 443]
# - path: /var/log/nginx/access.log # the machine-wide catch-all — every site again; left off
# log: http
It looks in every place we know — sshd, nginx, Apache and LiteSpeed, mail, MariaDB, BIND; Debian and Red Hat paths; the per-site layouts of Plesk, cPanel, DirectAdmin, ISPConfig and CyberPanel first, because the segment that names the site is worth keeping — and for the services' journald units when no file answers. A pattern that matches nothing here is written as a comment, so somebody moving their logs later finds the line waiting. It never reads the same line twice: where per-site logs exist the machine-wide catch-all is offered commented out, because a web server writes there for every site given no log of its own, and reading both would count one visitor twice and halve every threshold.
init says what it did. If it found none of the logs it knows, it says
that too: open the file and name yours under logs:. A path may be a
pattern, and on a machine hosting sites it should be: the pattern is
re-read every fifteen seconds, so a site created this afternoon is
watched this afternoon.
It enforces nothing yet
The first configuration is in test:
state: test
Every rule decides and records exactly as it would otherwise, and the data plane is never touched — no nftables table, no restore, not one element. That is deliberate. Nobody points a new blocker at a production machine and hopes: run it for a day beside fail2ban, CrowdSec or whatever already guards the box, and read what it would have done.
shield status # what it would be holding
shield bans # which addresses, by which rule
shield why 203.0.113.9 # the log lines that convicted one of them
shield tail # the decisions as they are made, with their lines
shield status answers enforcement DETECT-ONLY before any count, and
while the agent is in that mode the figures are the records it made
rather than the bans it holds.
Exempt yourself before going further, in
/etc/shieldlist/exemptions.yaml:
- ip: 203.0.113.7
note: the office
- range: 2001:db8:1234::/48
note: home
An exemption removes the sanction, never the observation: the traffic is
still parsed, still counted, still visible in shield why. The
machine's own addresses are never banned, without any entry.
When the decisions look right, set state: on, validate, restart.
Nothing else changes.
Check before running
shieldlist-agent -t
It validates everything and reports every problem at once, each with its file and line, then exits, changing nothing. Run it after every edit. It also answers three questions that only this machine can:
shieldlist-agent: configuration in /etc/shieldlist is valid —
35 parsers, 23 rules, 11 policies, 0 trust entries, 2 exemptions, 3 log inputs
test: every rule records what it would do, nothing is enforced
/var/www/vhosts/system/{target}/logs/proxy_access_ssl_log matches 23 files
enforcement: this kernel takes the agent's table
- what each pattern matches here, now — the moment to find out that a path is wrong is before the agent starts, not in a log nobody reads;
- whether a log connects nothing to nothing — a log input no parser reads, or a parser nothing feeds, both of which are silent;
- whether this kernel will take the table. The agent builds its own table under a throwaway name, with no hook so nothing is evaluated against a packet, puts elements in it, and removes it again. A kernel that refuses something says so here rather than during an attack.
Run
systemctl enable --now shieldlist-agent
journalctl -u shieldlist-agent -f
shield status
A starting agent says what it loaded:
2026-09-28 17:08:57 INFO shieldlist-agent version=0.1.0 config=/etc/shieldlist
2026-09-28 17:08:57 INFO loaded parsers=35 rules=23 policies=11 inputs="2 files, 1 journald"
2026-09-28 17:08:57 WARN test: every rule records what it would do, nothing is enforced
2026-09-28 17:08:57 INFO memory ceiling limit=956.6MB source="25% of machine memory"
and then very little, on purpose. A healthy agent is quiet: it speaks
when it decides something and when something is wrong. The log reflects
decisions, not traffic — a flood of a million lines produces a few
hundred lines of log, and the live view is shield status.
Before you trust a rule
Do not arm a rule and hope. Ask what it would have done to the logs you already have:
shieldlist-agent -dry-run 24h
shieldlist-agent -dry-run 24h -rule my-new-rule
It reads the last day, runs the rules over it, prints every crossing with the lines that caused it, and enforces nothing — judging by window and ban duration, so a crossing counts only if the sanction it earns would still be in force. See Replay.
Note that a dry run reports crossings, not decisions: it does not apply exemptions or trust, so an address you have exempted can appear in its output and still be spared when the agent runs.
Some shipped rules are off by default because they cannot tell a
customer from an attacker without a setting on your side — mail-auth
needs Dovecot to log the refused password's hash, mail-auth-fail is
the alternative when it cannot. The rule's own about: says so; read it
before switching one on.
Joining a controller
One machine is complete on its own. Several are better driven from one place — one set of rules for all, the bans one machine decides applied by the others, the whole fleet on one screen. With a controller running, mint a join token there and, on the machine:
shield enrol -controller https://shield.example.net:17453 <join-token>
From then on the controller decides what the machine runs and whether it is armed; the machine watches until it has been told. See Enrolment.
Where next
- Configuration — every file and every key
- Writing rules — parsers, thresholds, weights, policies
- Commands — the full CLI
- Enforcement — how a ban actually stops traffic
- Sizing and limits — what it costs the machine
- Troubleshooting — when something looks wrong
- What to expect in beta — what is solid, what will move, what is not built