Client Scripts¶
Bash scripts for client machines (FreeBSD/Linux; need bash and curl or wget). Download the current version from the server:
curl -fsSLO https://tis.cap-net.cz/get/tis-pull.sh && chmod +x tis-pull.sh
curl -fsSLO https://tis.cap-net.cz/get/tis-push.sh && chmod +x tis-push.sh
curl -fsSLO https://tis.cap-net.cz/get/tis-hits.sh && chmod +x tis-hits.sh
The sources are in scripts/ of the repository, together with the tis-cron.sh example.
Installer¶
Since v1.32.0. Every release has a self-extracting installer, tis-client-<version>.run (linked from the GitLab release, in the package registry under tis-client), that installs the scripts (tis-cron.sh, tis-hits.sh, tis-pull.sh, tis-push.sh) into one directory – tis-cron.sh finds the others next to itself – together with tis-cron.d/example.sh.sample:
sh tis-client-1.32.0.run -- --dry-run # what would change in /opt/tis
sudo sh tis-client-1.32.0.run # install or update /opt/tis
sh tis-client-1.32.0.run -- --dir ~/tis # another directory
New files are installed and identical ones left alone. Your steps in tis-cron.d/*.sh are never touched, the examples (*.sample) are updated without asking. For a script that differs (e.g. a locally edited one) it asks – overwrite, keep, show the diff, or overwrite all – and keeps the replaced file as <file>.bak.<timestamp>. Without a terminal (e.g. from Ansible) differing files are kept unless --overwrite is given.
| Option | Description |
|---|---|
-d, --dir <dir> |
Where to install (default /opt/tis, or TIS_INSTALL_DIR). |
-n, --dry-run |
Only show what would be installed or overwritten. |
-y, --overwrite |
Overwrite files that differ without asking. |
-N, --no-overwrite |
Never overwrite files that differ. |
-h, --help |
Help. |
The options go after --; before it are the archive's own makeself options (--check, --list, --target to only extract, …). Build one locally with scripts/build-installer.sh (needs makeself).
tis-pull.sh¶
Downloads an export and deploys it atomically: compares it with the current file, replaces it only when it changed, runs a reload command and rolls back if the reload fails. Common targets are built in – see Deployment targets.
tis-pull.sh --token "$TOKEN" --group customer-x --type fqdn \
--destination /usr/local/etc/namedb/master/tis-blocklist.zone --bind
tis-pull.sh --token "$TOKEN" --source 2 --type ipv4 --ipfw-table tis-blocklist
| Option | Env | Description |
|---|---|---|
--token * |
TIS_TOKEN |
API token. |
--format |
TIS_FORMAT |
plain, hosts, rpz, zone, csv, nftables, ipset, ipfw, dnsmasq, unbound (default plain). |
--type |
TIS_TYPE |
ipv4, ipv6, fqdn, all (default). |
--tag |
TIS_TAG |
Only indicators with this tag. |
--whitelist |
TIS_WHITELIST |
Comma-separated values to exclude. |
--rpz-ip <response\|client\|nsip> |
TIS_RPZ_IP |
With --format rpz: add IP indicators as RPZ IP triggers (since 1.19.0 of the script). |
--country <codes> |
TIS_COUNTRY |
Only IP indicators in these GeoIP countries, e.g. CZ,SK (since 1.21.0 of the script). |
--exclude-country <codes> |
TIS_EXCLUDE_COUNTRY |
No IP indicators in these countries, e.g. CN,RU (since 1.21.0 of the script). |
--group <id\|name> |
TIS_GROUP |
Pull a group (since 1.16.0 of the script). |
--source <ids> |
TIS_SOURCE |
Pull specific (private) sources, e.g. 5,6. Not with --group. |
--destination |
TIS_DESTINATION |
Target file (default: stdout). |
--reloadcmd |
TIS_RELOADCMD |
Run after a changed file was deployed. |
--revertcmd |
TIS_REVERTCMD |
Run after rolling back a failed reload (default: --reloadcmd again). |
--checkcmd |
TIS_CHECKCMD |
Validate the new file before deploying it; {} is its path (appended when missing). Failure → nothing deployed, exit 3. |
--ipfw-table, --pf-table, --ipset <name> |
Load into a firewall table, see Deployment targets (since 1.20.0). | |
--ipfw-rule <num> |
Add a rule to the IPFW firewall, see Deployment targets (since 1.21.0). | |
--ipfw-legacy |
TIS_IPFW_LEGACY |
Older ipfw without create … missing / or-flush (since 1.22.0). |
--bind, --unbound, --dnsmasq |
Validate and reload a DNS server, see Deployment targets (since 1.20.0). | |
--table-opts, --state-dir |
TIS_TABLE_OPTS, TIS_STATE_DIR |
Table creation options; where the loaded list is kept (default /var/db/tis, or /var/lib/tis without /var/db). |
--config |
TIS_CONFIG |
Shell file with TIS_* defaults (e.g. the token). |
--api-base |
TIS_API_BASE |
Default https://tis.cap-net.cz/api/v1/export. |
--retries, --retry-delay, --connect-timeout, --max-time |
TIS_RETRIES, … |
Network tuning (defaults 3, 2 s, 15 s, 120 s). |
--http-insecure |
TIS_HTTP_INSECURE |
Skip TLS certificate verification (insecure). The variable accepts 1, true, yes, on (case-insensitive) (since 1.22.0). |
--force |
Ignore the stored ETag: download and compare even if the server reports no change. | |
--dry-run, -q, -v, --color |
Preview, quiet, verbose, colours. |
Behaviour worth knowing:
- Without
--group/--sourceit pulls the public export (/export). - Network errors,
5xxand429are retried with back-off; other4xx(bad token, no access) abort immediately. - ETag: after a deploy the script stores the server's ETag in
<destination>.etagtogether with the file's checksum and sends it asIf-None-Matchnext time.304 Not Modified→ nothing is downloaded, written or reloaded. A list changed by hand no longer matches the checksum and is downloaded in full. - Unchanged content (ignoring the timestamp and SOA serial lines, in every format) → nothing is written or reloaded, exit 0. Before 1.16.1
hostslists were redeployed (and the service reloaded) on every run. - Concurrent runs for the same destination are serialised with
flock; file permissions and ownership are kept. - Exit codes:
0success/no change,1usage or download error,2reload failed (rolled back) or a table load failed (previous content kept),3validation failed (nothing deployed).
Deployment targets¶
Since 1.20.0 of the script. One option replaces the hand-written reload commands and pipes. Explicit --format, --reloadcmd, --revertcmd and --checkcmd still win.
Firewall tables – the download goes into a temporary table that is swapped in at once, so the live table is never empty or half-filled; if loading fails, the old content stays. Only IP addresses and networks are loaded (domains and URLs in the export are skipped). The loaded list is kept in --state-dir for the ETag and the comparison, so the table is touched only when the list changed – or when it doesn't exist (e.g. after a reboot).
| Option | What it does |
|---|---|
--ipfw-table <name> |
ipfw table <name> create … missing (--ipfw-legacy: plain create, flush the temp table), fill <name>_TEMP in one batch, swap, destroy. --table-opts default type addr. IPv4 and IPv6 in one table. |
--ipfw-rule <number> |
ipfw add <number> deny log logamount 0 ip from "table(<name>)" to me, no default |
--pf-table <name> |
pfctl -t <name> -T replace -f <list> (atomic, creates the table). |
--ipset <name> |
ipset restore into <name>_TEMP, swap, destroy. One family per set: --type ipv4 or ipv6. --table-opts default hash:net family inet\|inet6. |
DNS servers – the new file is validated before it is deployed (a broken file never reaches the server), then the service is reloaded; a failed reload is rolled back as usual. Need --destination.
| Option | Format | Validation | Reload |
|---|---|---|---|
--bind |
zone (default) |
named-checkconf <file> |
rndc reconfig |
--bind --format rpz |
rpz |
named-checkzone <zone> <file> |
rndc reload |
--unbound |
unbound |
unbound-checkconf <file> |
unbound-control reload |
--dnsmasq |
dnsmasq |
dnsmasq --test --conf-file=<file> |
service dnsmasq restart |
Keep the token out of the command line with a config file:
tis-push.sh¶
Pushes indicators from a file, URL or stdin into an import-only source (POST /api/v1/import/:id); the source's format decides how the text is parsed.
tis-push.sh --config tis.conf --source 7 --tag honeypot --input /var/log/honeypot/ips.txt
some-command | tis-push.sh --token "$TOKEN" --source 7
| Option | Env | Description |
|---|---|---|
--source <id> * |
TIS_SOURCE_ID |
Target source. |
--token * |
TIS_TOKEN |
Token with import/<id>. |
--input <path\|url> or positional |
TIS_INPUT |
Data to push (default stdin). |
--tag |
TIS_TAG |
Tags for the imported indicators. |
--config, --api-base, --retries, … |
As for tis-pull.sh (API base default …/api/v1/import). |
Pushed indicators expire after the retention period unless pushed again – push regularly from cron.
tis-hits.sh¶
Since v1.34.0. Reports how often the list blocked something to POST /api/v1/hits, for the statistics on the Dashboard and the indicators. It reads the BIND RPZ log (category rpz) and counts each rewrite for its trigger – the listed entry, so the counts match the indicators: *.evil.example → evil.example, RPZ IP triggers (rpz-ip, rpz-client-ip, rpz-nsip) → the address or network. PASSTHRU rewrites (whitelist) and other zones are not counted.
Since 1.1.0 of the script it also reads the ipfw log (--log-format ipfw, e.g. /var/log/security): the packets logged by the given rules, each counted for the entry of the ipfw table its source or destination matches (ipfw table … lookup) – a listed network collects the hits of all its addresses, and it doesn't matter whether the rule matches the source or the destination. Without --ipfw-table (e.g. when testing on another machine) the address on --side is counted. last message repeated N times from syslog counts N times.
tis-hits.sh --token "$TOKEN" --log /var/log/security --log-format ipfw --rule 4990 --ipfw-table tis-blocklist
| Option | Env | Description |
|---|---|---|
--token * |
TIS_TOKEN |
API token with the hits ACL (in the guest default). |
--log * |
TIS_HITS_LOG |
BIND log file with the rpz category, or the ipfw log. |
--log-format |
TIS_HITS_FORMAT |
rpz (default) or ipfw. |
--zone * (rpz) |
TIS_RPZ_ZONE |
The RPZ zone of the TIS list as named in named.conf; comma-separated for more. |
--rule * (ipfw) |
TIS_IPFW_RULE |
Number(s) of the logging rule(s), comma-separated. |
--ipfw-table (ipfw) |
TIS_IPFW_TABLE |
Count for the matching entry of this table; other packets are skipped. |
--side <src\|dst> (ipfw) |
TIS_IPFW_SIDE |
Without --ipfw-table: count the source or destination address. |
--reporter |
TIS_REPORTER |
The machine's name in the statistics (default: hostname). |
--state-dir |
TIS_STATE_DIR |
Where the read position is kept (default /var/db/tis or /var/lib/tis). |
--from-start |
On the first run count the whole log (default: start at its end). | |
--dry-run |
Print the counts, send nothing, keep the position. | |
--config, --api-base, --retries, … |
As in tis-push.sh. |
Only new lines are read: the position (inode and offset) is saved after the report was accepted, so a failed run is simply repeated; a line still being written waits for the next run, and a rotated log is detected and read from its start. Run it as a tis-cron.sh step, e.g. every 5 minutes – the hits count in the hour of the report.
BIND has to log the rpz category to a file, e.g.:
logging {
channel rpz_log { file "/var/log/named/rpz.log" versions 3 size 20m; print-time yes;
print-category yes; print-severity yes; severity info; };
category rpz { rpz_log; };
};
For ipfw the rule has to log and the kernel messages end up in security.log (the FreeBSD default with net.inet.ip.fw.verbose=1); mind net.inet.ip.fw.verbose_limit, which stops logging a rule after that many packets until ipfw resetlog:
ipfw add 4990 deny log ip from table(tis-blocklist) to any
ipfw add 4991 deny log ip from any to table(tis-blocklist)
tis-cron.sh¶
A wrapper for cron that runs several steps, logs them and e-mails the transcript when a step fails (TIS_CRON_NOTIFY_EMAIL, needs mail).
Since v1.35.0 the steps are not in the script itself but in tis-cron.d/ next to it: every *.sh file there is run in name order, so updating tis-cron.sh never overwrites them. Start from tis-cron.d/example.sh.sample (*.sample files are not run):
# /opt/tis/tis-cron.d/10-blocklists.sh
run_step "pull zone blocklist (group customer-x) -> named" \
tis-pull.sh --config 'tis.conf' --group 'customer-x' --type 'fqdn' \
--destination '/usr/local/etc/namedb/master/tis-blocklist.zone' --bind
run_step "pull ipv4 blocklist (source 2) -> ipfw table tis-blocklist" \
tis-pull.sh --config 'tis.conf' --source '2' --type 'ipv4' --ipfw-table 'tis-blocklist'
run_step <description> <command…> runs a command, run_pipe <description> '<a | b>' a pipeline that fails when any part of it fails. --steps <dir|file> (or TIS_CRON_STEPS) runs another directory or a single file – e.g. the hits more often than the lists:
*/15 * * * * /opt/tis/tis-cron.sh --steps /opt/tis/tis-cron.d/10-blocklists.sh
*/5 * * * * /opt/tis/tis-cron.sh --steps /opt/tis/tis-cron.d/50-hits.sh
Without any steps the run fails with an error. Upgrading from an older version: steps you had edited into tis-cron.sh are in the installer's backup tis-cron.sh.bak.<timestamp> – move them into a tis-cron.d/*.sh file.
Healthcheck¶
Since v1.28.0. tis-cron.sh can report every run to a Healthchecks.io-compatible monitor, so a cron job that stops running or keeps failing raises an alert:
*/15 * * * * /opt/tis/tis-cron.sh --healthcheck-url 'https://hc-ping.com/<uuid>' --healthcheck-qs "?rid=$(uuidgen)"
| Option | Env | Description |
|---|---|---|
--healthcheck-url <url> |
TIS_CRON_HEALTHCHECK_URL |
GET <url>/start before the steps, POST <url>/log with each step's output, finally POST <url>/<exit-code> with the whole transcript (0 = success). |
--healthcheck-qs <qs> |
TIS_CRON_HEALTHCHECK_QS |
Appended verbatim to every ping URL – include the leading ? or &. ?rid=<uuid> ties the pings to one run. |
--notify-email <addr> |
TIS_CRON_NOTIFY_EMAIL |
Mail the transcript when a step fails. |
- Uses
curl(orwget); a ping that can't be sent is logged as a warning and never fails the run. - Bodies are cut to their last 100 kB (the Healthchecks.io limit), so the end of the output – where errors are – is kept.
- If the script dies unexpectedly, the failure is still reported with its exit code.
Integrations¶
| Target | Format | Notes |
|---|---|---|
| BIND RPZ | rpz |
response-policy { zone "rpz.tis"; }; + zone file from --destination; --bind --format rpz. |
| BIND empty zones | zone, type=fqdn |
include the file in named.conf; provide master/empty.db; --bind. |
| Unbound | unbound (or rpz with the rpz: module) |
include: <file>; --unbound. |
| nftables | nftables |
--reloadcmd 'nft -f <file>'; reference @blocklist_v4 / @blocklist_v6 in your rules. |
| iptables + ipset | plain, type=ipv4/ipv6 |
--ipset <name>; match with -m set --match-set <name> src. (The ipset format with --reloadcmd 'ipset restore < <file>' still works.) |
| dnsmasq | dnsmasq |
conf-file=<file>; --dnsmasq. |
| ipfw / pf | plain |
--ipfw-table <name> / --pf-table <name>. (The ipfw format with --reloadcmd 'ipfw -q /absolute/path/to/file' loads the fixed table tis-blocklist.) |
/etc/hosts, Pi-hole |
hosts |