10 Getting Started
Robin edited this page 2026-09-28 11:13:27 +00:00

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 nft binary 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 journalctl when 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