One static Go binary that guards Pterodactyl/Wings nodes, Docker hosts and bare VPS boxes against miners, outbound DDoS, port scans, Tor exits, proxies, webshells, zip bombs and exploits — then alerts and shuts them down automatically.
- What Protection Plus is (and is not)
- Feature matrix
- Installation
- Quick start
- CLI reference
- Configuration reference
- Rules engine & actions
- Alert channels
- Technical design
- Detection methods
- Security & privacy
- Troubleshooting
- Building from source & development
If you rent out game servers, VPS slices or Docker containers, some of your customers will abuse them. They run cryptocurrency miners on your CPUs, launch DDoS attacks at other people's networks, scan the internet from your IPs, host Tor exits and open proxies, upload webshells and malware through your panel, drop zip bombs into upload directories and try to escape their containers. Every one of these costs you money, bandwidth, and eventually your IP reputation and your upstream's patience.
Protection Plus is a single-binary, CGO-free Linux daemon that watches a host
from the outside and deals with exactly that class of abuse. It reads /proc
directly (no agents inside containers, nothing tenants can see or tamper
with), gathers connections from every container's network namespace, inspects
archives without extracting them, scans uploads the moment they land on disk
with a SHA-256 blocklist and YARA, and enforces through a simple rule set:
kill the container, suspend the Pterodactyl server, quarantine the file, kill
the process — and alert you on Discord, email or a webhook.
It is built for hosting providers: Pterodactyl/Wings nodes, generic Docker hosts, and plain bare-metal VPS boxes. One policy works on all three.
What it is not: Protection Plus is egress protection. It stops your customers from attacking others; it does not mitigate inbound DDoS attacks aimed at your nodes. Keep using your firewall/XDP/upstream layer for that.
Design highlights:
- Single static binary, zero cgo. Drop it on any Linux amd64/arm64 node. No runtime, no SDK, no kernel modules.
- Full container network visibility. Container sockets never appear in the
host's connection table, so Protection Plus reads each container's network
namespace via
/proc/<pid>/net/tcp— a miner phoning home from inside a container is seen and attributed to that container. - Pterodactyl-native. Offending containers and volume files are mapped back to the owning server UUID and can be suspended through the Application API.
- Event-driven where it matters. An active extraction (CPU + disk-write spike) triggers an immediate zip-bomb inspection; uploads are scanned by fsnotify the moment they are closed after writing.
- Cheap to run. One shared
/procsnapshot per scan tick feeds every detector. Measured on live nodes: ~13 MB RSS and 1–3% CPU at the default 5-second interval — and a synthetic 104K pps outbound flood was detected and attributed to the offending container. - Safe by default. Ships in
dry_run: true: it detects and alerts but never enforces until you arm it.
All ten detectors are independent modules — delete a block from the config (or
set enabled: false) and that feature disappears completely.
| Detector | Category | What it catches | Default state |
|---|---|---|---|
miner |
miner |
Known miner binaries (xmrig, t-rex, nbminer…), miner argument fingerprints (stratum+tcp, --donate-level…), sustained high CPU from unknown binaries, processes running from deleted executables, and connections to mining-pool ports |
enabled |
portscan |
portscan |
Half-open (SYN_SENT) fan-out across many ports/hosts in a sliding window, plus known scanner binaries (nmap, masscan, zmap, rustscan…) |
enabled |
ddos |
ddos |
Per-container egress floods (pps/bps via docker stats), stress tools (hping3, t50, mhddos, LOIC/HOIC…), Java flooders (java -jar *ddos.jar), and per-process connection floods |
enabled |
zipbomb |
zipbomb |
Decompression bombs (zip, jar, tar, gzip, xz, bzip2, 7z, rar) detected from archive metadata without extracting — caught both mid-extraction (event-driven hot trigger) and on disk (periodic sweep) | enabled |
exploit |
exploit |
Exploit/privesc tools (pwnkit, dirtypipe, linpeas, nsenter…), network-bound reverse-shell patterns (/dev/tcp/, nc -e, socat exec…), privilege escalation (chmod +s, sudoers tampering, namespace escapes), execution from world-writable dirs, and setuid payloads dropped in them |
enabled |
abuse |
abuse |
Hosting-abuse services: Tor exits/relays, proxy/VPN tunnels (OpenVPN, WireGuard, Shadowsocks, v2ray/xray, trojan, hysteria, sing-box, ngrok, frp…), customer mail servers (spam), IRC bots, listeners on Tor/SOCKS/proxy ports, and executables launched from user upload dirs | enabled |
onaccess |
malware |
The antivirus hot path: fsnotify watches upload dirs and scans every file the moment it is closed after writing — SHA-256 against the MalwareBazaar hash blocklist, then YARA against the curated rule bundle | enabled |
fim |
system |
File-integrity monitoring: alerts when the daemon's own binary, its config, or any listed path changes on disk | enabled |
yara |
abuse |
Periodic full-tree YARA sweep of scan paths with the yara CLI (installed separately; no-op if missing) |
disabled |
trivy |
system |
Container-image vulnerability scanning via the trivy CLI — one alert per image with HIGH/CRITICAL vulnerability counts (installed separately; no-op if missing) |
disabled |
curl -fsSL https://raw.githubusercontent.com/AnAverageBeing/protection/main/install.sh | sudo bashThe installer detects your architecture (amd64 or arm64), downloads the
prebuilt static binary from the latest release to /usr/local/bin/protection,
installs the systemd unit, creates /var/lib/protection, and then asks a few
quick questions on the terminal (it works even when piped through
curl | bash):
? Name this installation [node-fra-01]:
? Protect what? (server / docker / both) [both]:
? Discord webhook URL for alerts (blank to skip):
? Using Pterodactyl? auto-suspend abusive servers (y/N): y
? Panel URL (e.g. https://panel.example.com):
? Application API key (ptla_...):
? Directories to scan for zip bombs (comma-separated, or ALL) [/var/lib/pterodactyl/volumes]:
? Arm enforcement now? 'no' keeps safe dry-run mode (y/N): N
Notes on the questions:
- Scan directories accepts a single path
(
/var/lib/pterodactyl/volumes), a comma-separated list (/var/lib/pterodactyl/volumes,/home), or the literalALL, which expands to the standard user-writable locations (/tmp,/var/tmp,/dev/shm,/home, plus/var/lib/pterodactyl/volumes,/var/lib/pterodactyl/mounts,/var/www,/srvand/optwhen they exist). The answer fillszipbomb.scan_paths,yara.scan_pathsandonaccess.watch_paths. - Arm enforcement controls
dry_run. AnswerNand the daemon detects and alerts but touches nothing — review alerts for a few days, then arm it. - If
/etc/protection/config.yamlalready exists it is left untouched. - The service is enabled and started automatically when systemd is available.
Requires Go ≥ 1.22.
git clone https://github.com/AnAverageBeing/protection.git
cd protection
sudo make install # builds bin/protection, installs binary + systemd unit
sudo protection config init # writes /etc/protection/config.yaml (dry_run: true)
sudo systemctl daemon-reload && sudo systemctl enable --now protectionmake install honours PREFIX (default /usr/local) and DESTDIR.
After installing:
# 1. Pull the threat intel (YARA rule bundle + SHA-256 malware blocklist).
# Required once for on-access hash scanning.
sudo protection rules update
# 2. See what the daemon sees, right now — no actions are ever taken here.
sudo protection scan
# 3. Verify your alert channels end-to-end.
sudo protection test-alert
# 4. Run dry for a few days and watch what it WOULD do.
journalctl -u protection -f
# 5. Happy with the signal-to-noise? Arm enforcement.
sudo nano /etc/protection/config.yaml # set dry_run: false
sudo systemctl restart protectionGlobal flag for every command: --config <path> (default
/etc/protection/config.yaml).
| Command | Description |
|---|---|
protection run |
Start the daemon (aliases: start, daemon). Loads config, builds detectors/alerters/actions, starts the intel auto-updater and the scan loop. Warns if not running as root. |
protection scan |
Run every detector once against a fresh system snapshot and print findings as a severity-sorted table (SEVERITY CATEGORY TARGET TITLE). Takes no action and ignores cooldowns — pure reconnaissance. Exits after at most 60 s. |
protection status |
Show the loaded config: installation name, mode, scan interval, dry-run state, enabled detectors, enabled alert channels, and a live Docker connectivity check. |
protection config init [path] |
Write a fully documented starter config (mode 0640) and validate it. Refuses to overwrite an existing file. Flags: --name, --mode server|docker|both, --dry-run true|false, --discord-webhook <url>, --pterodactyl-url <url>, --pterodactyl-key <ptla_…>, --scan-paths <a,b|ALL>. |
protection config check [path] |
Parse, apply defaults and validate a config file; prints ✓ <path> is valid or the exact problem. |
protection test-alert |
Fire a synthetic critical alert through every enabled channel and report per-channel delivery. The way to confirm Discord/SMTP/webhook wiring. |
protection rules update |
Download the latest YARA rule bundle and the MalwareBazaar SHA-256 blocklist (writes are atomic). Prints where each artifact landed and how many hashes were loaded. |
protection debug-conns [port] |
Dump every TCP/UDP connection Protection Plus can see — host plus every container network namespace — with owning PID, process name and container ID, as a table. Optional numeric argument filters by local or remote port. The quickest way to verify container visibility. |
protection version |
Print the version (aliases: -v, --version). |
Exit codes: 0 success, 1 runtime error, 2 usage error.
Everything lives in one YAML file (default /etc/protection/config.yaml).
Every section is modular, and anything you omit falls back to a documented
default, so a near-empty config still works. The tables below list every
supported key; "default" is the value applied when the key is absent. The
shipped config.example.yaml carries the same
information as inline comments.
One caveat: boolean
enabledflags anddry_runhave no implicit default — omitting them meansfalse. The shipped config and the installer explicitly setdry_run: true; keep it that way until you have reviewed alerts.
| Key | Default | Description |
|---|---|---|
name |
hostname → primary IP → "Protection" |
Human label for this installation, shown in every alert. Use something that tells you which node is paging you at 3am (node-fra-01, not vps). |
mode |
both |
Scope: server = host/VPS processes only, docker = containerised threats only, both = everything (recommended for game hosts). |
scan_interval |
5s |
How often detectors run. Lower = faster detection, more CPU. 5 s is the sweet spot; 10 s is fine on small nodes; do not go below 2 s. |
cooldown |
5m |
Suppresses duplicate alerts/actions for the same threat on the same target. Raise if you get alert fatigue; lower for faster re-alerts while a threat persists. |
log_level |
info |
debug (noisy, for troubleshooting), info, warn, error. |
log_file |
(none — stderr/journald only) | Log file path; the shipped config uses /var/log/protection.log. Alerts and enforcement always go to stdout/journald too. Built-in rotation is available under limits:; leave limits.log_max_size_mb at 0 if you prefer logrotate. |
dry_run |
(none — false if omitted; shipped config sets true) |
Safety switch. true = detect and alert only, never kill/quarantine. Run dry on a new node, review the alerts, then arm it. |
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch for the detector. |
cpu_threshold |
85 |
Per-core CPU% a process must sustain to be treated as a possible unknown miner. Game servers legitimately sit at 90%+; that is what the whitelist below is for. Recommended: 80–90. |
sustained_seconds |
45 |
How long CPU must stay above the threshold before flagging. Miners are constant; game servers spike and dip. 45 s separates them well. |
known_processes |
built-in list (xmrig, minerd, t-rex, nbminer, teamredminer, srbminer, …) | Known miner binary names. Setting this replaces the built-in list — copy the defaults and extend if you want both. |
pool_ports |
built-in list (3333, 4444, 5555, 7777, 8888, 9999, 14433, 14444, 45560, 45700, 20580) | Mining-pool ports. Only public remote IPs are checked, so local services on the same ports are safe. Replaces the built-in list when set. |
pool_domains |
built-in list (minexmr, nanopool, ethermine, nicehash, …) | Known pool domains, parsed and defaulted for forward compatibility. Detection today is connection/port-based; harmless to extend. |
whitelist_processes |
built-in game-server list (java, bedrock_server, srcds, RustDedicated, fivem, valheim, …) | Processes skipped by the CPU heuristic (known miner signatures still apply). Replaces the built-in list when set. |
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
distinct_ports |
100 |
Flags a process fanning out half-open (SYN_SENT) connections to this many distinct destination ports within window. Legit servers do not do this; scanners do. Lower = more sensitive, more false positives from peer-to-peer software. |
distinct_hosts |
50 |
Same, for distinct destination hosts. |
window |
15s |
Sliding accumulation window for the two counters above. |
known_scanner_processes |
built-in list (nmap, masscan, zmap, unicornscan, hping3, naabu, rustscan) | Scanner binaries that flag on sight. Replaces the built-in list when set. |
Your customers attacking other people. This is the anti-DDoS-source side; it does not mitigate inbound attacks.
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
pps_threshold |
60000 |
Packets/sec per container, measured via docker stats. 60000 ≈ a real flood; game servers rarely exceed 10–20k pps. Lower it on small hosts (e.g. 20000) for earlier warnings. |
bps_threshold |
125000000 |
Bytes/sec per container (~1 Gbit/s). Set this to ~80% of your node's uplink so a single container cannot saturate you. |
conn_threshold |
1500 |
Simultaneous outbound TCP connections (or active UDP sockets) per process. Tor exits and floods hold thousands; game servers hold dozens per player. 1500 is conservative; 500 is aggressive. |
known_tools |
built-in list (hping3, t50, mhddos, slowloris, goldeneye, xerxes, loic, hoic, …) | Stress-tool signatures, word-boundary matched so generic fragments never cause false positives. Replaces the built-in list when set. |
whitelist_processes |
built-in game-server list | Processes skipped by the connection-flood heuristic (docker-stats rates and tool signatures still apply). |
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
scan_paths |
[/var/lib/pterodactyl/volumes] |
Directories walked for archive bombs. Set to your panel volumes and/or anywhere users can upload. protection config init --scan-paths fills this in (use ALL for all user-writable dirs). |
ratio_threshold |
150 |
uncompressed:compressed ratio that marks a bomb. Zip bombs are >1000:1; media archives ~1:1; text logs ~10:1. 150 is safe. |
max_uncompressed |
53687091200 (50 GiB) |
Absolute uncompressed ceiling regardless of ratio. Lower on small disks (e.g. 10 GiB). |
max_nesting |
3 |
Accepted for forward compatibility; inspection is currently metadata-first and does not recurse into nested archives. |
hot_trigger |
true |
Event-driven path: when a process spikes CPU + disk writes (the signature of an active extraction — measured per process group so tar+gzip pipelines are caught), the archives it has open are inspected immediately instead of waiting for the next sweep. Keep this on. |
hot_cpu_percent |
80 |
Per-core CPU% (combined across the process group) that counts as "extracting". |
hot_write_mbps |
25 |
Disk write rate in MB/s (combined across the process group) that counts as "extracting". |
full_scan_interval |
30m |
Slow backstop sweep of scan_paths. The hot trigger catches live extractions; this catches bombs uploaded but not yet extracted. |
full_scan_max_duration |
5m |
Stops a sweep after this long; already-cleared files are skipped next time so it resumes where it left off. |
min_compressed_size |
10240 (10 KiB) |
Archives smaller than this are skipped — they cannot hide a meaningful bomb. |
inspect_timeout |
5s |
Hard cap per archive inspection. |
max_concurrent_inspects |
4 |
Worker-pool size for sweeps. |
probe_compressed_limit |
1048576 (1 MiB) |
Bounded probe for stream formats (xz, bzip2, wrapped gzip): at most this many compressed bytes are read… |
probe_uncompressed_limit |
10485760 (10 MiB) |
…and at most this many decompressed bytes are produced before extrapolating the total. |
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
flag_reverse_shell |
true in shipped config |
Match network-bound reverse-shell patterns: /dev/tcp/, nc -e, socat exec, bash -i >&… Bare interactive shells are deliberately ignored. |
flag_privilege_escalation |
true in shipped config |
Match privesc/escape patterns: chmod +s, sudoers edits, setcap, nsenter --target 1 (a container escape ⇒ critical). |
watch_paths |
[/tmp, /dev/shm, /var/tmp] |
World-writable dirs scanned for setuid/setgid payloads and flagged as suspicious execution origins. Add your custom tmpfs mounts. |
suspicious_processes |
built-in list (dirtycow, dirtypipe, pwnkit, linpeas, nsenter, deepce, …) | Known exploitation tools. Replaces the built-in list when set. |
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
known_processes |
built-in list (tor, openvpn, wireguard, shadowsocks, v2ray, xray, trojan, hysteria, sing-box, cloudflared, frpc/frps, ngrok, sendmail, postfix, exim, znc, eggdrop, …) | Abuse-service binaries. Replaces the built-in list when set. |
known_cmd_patterns |
built-in list (--orport, --socksport, --protocol vmess, inbound:, listen=0.0.0.0, …) |
Command-line substrings that strongly indicate abuse services. |
abusive_ports |
built-in list (Tor 9001/9030/9050/9051/9150/9151, SOCKS 1080/1081/1090, proxy 7890/7891/8000/8001/8008/8082, Shadowsocks 8388/8389, trojan/xray 4433/8443, hysteria/sing-box 10086/12345/54321) | Listening ports that indicate abuse tunnels. Replaces the built-in list when set. |
watch_upload_paths |
[/var/lib/pterodactyl/volumes, /var/lib/pterodactyl/mounts, /home/container, /tmp, /var/tmp, /dev/shm] |
Executables must never run from upload dirs. |
whitelist_processes |
built-in game-server list | Game servers etc. exempt from abuse heuristics. |
Full-tree scanning of scan_paths with the yara CLI (installed separately,
e.g. apt install yara). A permanent no-op if the binary is missing —
shelling out instead of linking go-yara keeps the daemon CGO-free. For instant
per-file scanning prefer onaccess; this is the periodic backstop.
| Key | Default | Description |
|---|---|---|
enabled |
false |
Off by default; the on-access scanner covers the hot path. |
rules_dir |
/etc/protection/yara |
Directory scanned recursively (yara -r -f). rules update drops protection.yar here. |
scan_paths |
[/var/lib/pterodactyl/volumes] |
Trees to sweep. |
interval |
10m |
Minimum time between full passes (scanning whole trees is expensive). |
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
paths |
the daemon's own binary + its config file | Files to hash-watch. Add anything an attacker tampering with Protection Plus itself — or your SSH config — would touch (e.g. /etc/ssh/sshd_config). |
interval |
5m |
Time between hashing passes. Each change alerts exactly once (the new hash becomes the baseline). |
Container-image vulnerability scanning via the trivy CLI (installed
separately). A permanent no-op if the binary is missing. One event per
image with HIGH/CRITICAL vulnerability counts, including the top CVEs.
Alert-only by design.
| Key | Default | Description |
|---|---|---|
enabled |
false |
Off by default (requires trivy installed). |
binary |
trivy |
Binary name/path to invoke. |
interval |
1h |
Time between passes (image scans are expensive). |
min_severity |
medium |
Gate for the events this detector feeds the engine: medium (any HIGH present) escalates to high when a CRITICAL is present. |
The antivirus hot path. Watches upload dirs with fsnotify and scans every file
the moment it is closed after writing: SHA-256 against the hash blocklist,
then YARA against the rule bundle. Matched files hit the malware rule
(quarantine + alert by default).
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Master switch. |
watch_paths |
the six upload paths listed under abuse.watch_upload_paths |
Trees to watch (recursive; new subdirectories are picked up automatically). |
hash_check |
true |
SHA-256 vs the blocklist. Needs one protection rules update first; until then the blocklist is simply empty. Files over 512 MiB are skipped. |
yara_check |
true |
YARA scan of each settled file. Needs the yara CLI installed; silently self-disables when it is not. Files over 64 MiB are skipped; each scan is capped at 30 s. |
settle_ms |
500 |
Wait for writes to settle before scanning — editors and downloaders write in bursts, and scanning a half-written file wastes I/O and can misfire. |
Where rules and the hash blocklist live and how they refresh.
protection rules update runs this once manually; the daemon refreshes
automatically every update_interval when enabled. All downloads are written
atomically (temp file + rename), so readers never see a partial file.
| Key | Default | Description |
|---|---|---|
enabled |
true in shipped config |
Enables the daemon-side auto-refresh. |
rules_dir |
/etc/protection/yara |
Where protection.yar is written. |
rules_url |
the project's curated rules/protection.yar |
Curated rule bundle shipped with the project (webshells, miners, Tor, Mirai, IRC bots, Linux backdoors, privesc exploits, crypto stealers). Point at your own URL to self-host. |
hashlist_url |
MalwareBazaar full SHA-256 export (…/txt/sha256/full/) |
Effectively all publicly known malware hashes. For a lighter list use …/txt/sha256/recent/ instead — the full export is 1M+ hashes and costs a few hundred MB of RAM in memory. Raw-text and zipped exports are both accepted. |
hashlist_file |
/var/lib/protection/blocklist.sha256 |
Where the parsed blocklist is stored (one lowercase hash per line). |
custom_hashlist |
"" |
Your own extra hashes, one SHA-256 per line. Merged into the blocklist, never overwritten by updates. |
update_interval |
24h |
Daemon-side refresh cadence. Failures are logged and retried at the next interval; each source (rules / hashes) is attempted independently. |
Each channel has its own min_severity gate (info < low < medium < high < critical; unknown values fall back to medium). See Alert
channels for payload details.
| Key | Default | Description |
|---|---|---|
discord.enabled / webhook_url / username |
false / "" / Protection |
Rich, severity-coloured Discord embeds. Enabling without a URL is a config error. |
discord.min_severity |
medium |
medium is a good default; use high on quiet channels. |
smtp.enabled, host, port, username, password, from, to, tls |
false, port 587 in shipped config |
Plain-text email. 587 = STARTTLS, 465 = implicit TLS; authenticated or open relay (leave username empty). Enabling without host/to is a config error. |
smtp.min_severity |
high in shipped config |
Email is the noisy channel — gate it higher. |
webhook.enabled, url, method, headers |
false, POST, none |
POSTs the full event as JSON ({"installation": …, "event": {…}}) for SIEM/automation, with custom headers. Enabling without a URL is a config error. |
webhook.min_severity |
medium |
Per-channel gate. |
batch.enabled |
false |
Alert aggregation: when more than threshold alerts fire inside window, they collapse into one digest alert. Enable on busy nodes so a burst pages you once, not 50 times. |
batch.threshold |
10 |
Alerts inside the window that trigger digest mode. |
batch.window |
1m |
Aggregation window. |
| Key | Default | Description |
|---|---|---|
docker.enabled |
true in shipped config |
Enables kill_container / stop_container. If the socket is unreachable the daemon logs a warning and container enforcement is disabled — everything host-side keeps working. |
docker.socket |
/var/run/docker.sock |
Docker Engine API unix socket. |
pterodactyl.enabled, url, api_key |
false |
Enables suspend_server via the Application API (needs a key with server read + suspend). Enabling without url/api_key is a config error. |
file.enabled |
true in shipped config |
Enables quarantine_file / delete_file. |
file.quarantine_dir |
/var/lib/protection/quarantine |
Quarantine destination (created 0700; files land there chmod 000). |
Whitelisted paths are never scanned or flagged, even if they also fall under a scan/watch path; whitelisted containers are never flagged, killed, or suspended. Use this for known-good build dirs, your own admin containers, or anything Protection Plus must never touch.
| Key | Default | Description |
|---|---|---|
paths |
[] |
Prefix-matched: a whitelisted directory exempts everything beneath it (e.g. /srv/trusted-builds). |
containers |
[] |
Full ID, short (12-char) ID, or container name. |
All disabled by default — turn these on if the daemon itself ever misbehaves on a huge or heavily loaded node. Every field is opt-in so upgrades never change behavior.
| Key | Default | Description |
|---|---|---|
detector_timeout |
0s (disabled) |
Hard cap per detector per tick; a detector that exceeds it is aborted and logged. |
max_alerts_per_minute |
0 (disabled) |
Global alert rate limit; excess alerts are dropped (with a once-per-minute warning in the log). |
max_setuid_walk_files |
0 (disabled) |
Caps how many files the exploit detector walks per scan when hunting setuid binaries. |
cache_directory_mtimes |
false |
Skip walking directories whose mtime has not changed since the last zipbomb/exploit scan. Best-effort: some filesystems do not update directory mtime on every file change. |
log_max_size_mb |
0 (disabled) |
Rotate general.log_file at this size. Leave at 0 if you use logrotate. |
log_max_backups |
0 |
Rotated log files to keep (rotation requires both this and log_max_size_mb > 0). |
See the next section. When rules: is omitted entirely, the built-in default
policy below is used.
Rules map category + minimum severity → actions. They are evaluated
top-to-bottom and every matching rule contributes its actions (the union
is executed), so the catch-all at the bottom layers alert under any rule
above it. categories: ["*"] matches everything.
Available actions:
| Action | Effect |
|---|---|
alert |
Notify all alert channels (subject to each channel's severity gate). |
neutralize |
Smart: kills the container for containerised threats, else SIGKILLs the host process. Used by the default rules so one policy works on Docker nodes and bare VPS alike. Falls back to process-kill when Docker is unavailable. |
kill_container |
SIGKILL the offending container immediately — the fastest way to stop an active miner, flood or exploit. |
stop_container |
Graceful stop (10 s grace). |
suspend_server |
Suspend the server via the Pterodactyl Application API (silently skipped for host threats with no associated server, so it can sit in a shared rule). |
quarantine_file |
Move the file to quarantine_dir (timestamp-prefixed), chmod 000. Preserves evidence; handles cross-filesystem moves. |
delete_file |
Permanently remove the file. Prefer quarantine until you trust your false-positive rate. |
kill_process |
SIGKILL the offending PID (refuses PID ≤ 1). |
log_only |
Record only — the event is logged, nothing else happens. |
The default policy (identical to the shipped config):
rules:
- {name: miners, categories: [miner], min_severity: high, actions: [neutralize, suspend_server, alert]}
- {name: ddos, categories: [ddos], min_severity: high, actions: [neutralize, suspend_server, alert]}
- {name: abuse, categories: [abuse], min_severity: high, actions: [neutralize, suspend_server, alert]}
- {name: malware, categories: [malware], min_severity: high, actions: [quarantine_file, alert]}
- {name: exploits, categories: [exploit], min_severity: high, actions: [neutralize, alert]}
- {name: zipbombs, categories: [zipbomb], min_severity: medium, actions: [quarantine_file, alert]}
- {name: portscans, categories: [portscan], min_severity: medium, actions: [alert]}
- {name: catch-all, categories: ["*"], min_severity: low, actions: [alert]}Matching details:
- Severity ladder:
info < low < medium < high < critical. A rule fires when the event's severity is ≥min_severity. An unrecognised severity string parses asmedium, so a typo never silently disables a rule. - Cooldown: a
general.cooldown(default 5 m) suppresses duplicate alerts/actions for the same threat on the same target. The dedup key iscategory|detector|target, where target is the container ID, the file path, orpid:<n>— two findings about the same miner in the same container collapse into one page. - Whitelist first: events whose path or container matches the
whitelist:section are dropped before rules ever see them. Paths are prefix-matched; a whitelisted directory exempts everything beneath it. Containers match by full ID, short ID, or name and are never flagged, killed, or suspended. - Mode scoping:
general.modefilters events by origin — container events (those with a container ID or Pterodactyl server) belong todockerscope, everything else toserverscope. - Dry run: with
dry_run: trueevery destructive action is logged as[dry-run] would run action …and skipped. Alerts still fire.
Every channel dispatches in its own goroutine with a 12 s timeout and honours
its own min_severity gate. Test the whole pipeline with
protection test-alert.
- Discord — rich embeds: severity-coloured, category emoji, the human
description, a bold target line, an inline metadata grid (installation,
container, Docker ID, Pterodactyl server, process+PID, path) and the full
evidence map as a YAML block.
usernameoverrides the webhook display name. - SMTP — plain-text email with the same metadata and evidence. STARTTLS (587) or implicit TLS (465), authenticated or open relay.
- Webhook — POSTs
{"installation": "<name>", "event": {…}}with the complete event as JSON (severity, category, target fields, evidence map) to any endpoint, with configurable method and headers — wire it into a SIEM, PagerDuty, n8n, or your own automation.
Alert batching. When alerts.batch is enabled, alerts flow individually
until more than threshold fire inside window; then individual alerts pause
and everything is collected until the window closes, after which one digest
alert summarizing the burst (3× miner: Cryptocurrency miner running; 1× ddos: Outbound flood…, at the highest severity seen) is sent. Enable it on
busy nodes so a burst pages you once. Independently,
limits.max_alerts_per_minute can hard-cap total alert volume.
One shared snapshot per tick. Each scan interval the engine calls
system.Gather() once — a full /proc process walk (stat, cmdline, exe, uid,
cgroup, I/O counters) plus the merged connection table — and hands that single
coherent snapshot to every detector. This cuts per-tick syscall cost by ~4×
compared with each detector walking /proc on its own, and guarantees all
detectors reason about the same instant.
Per-container network namespace gathering. Docker gives each container its
own network namespace, so container sockets never appear in the host's
/proc/net/tcp. Protection Plus buckets processes by network-namespace inode
and reads /proc/<pid>/net/tcp{,6} (and udp{,6}) through a representative
process of each namespace. Two wins: connections are attributed to their
container for free, and socket→PID resolution only has to scan that
container's few processes — no host-wide fd walk. Containers in host-network
mode are attributed via the PID→container map from /proc/<pid>/cgroup
(a 64-hex container ID; works on cgroup v1/v2, docker/containerd/k8s).
Socket→PID resolution with a time budget. Mapping a socket inode to its
owning PID means readlinking /proc/<pid>/fd/* — hundreds of thousands of
syscalls on a busy host. The walk is fanned out across a worker pool (4×CPU,
min 4, max 64 workers) with per-process fd tables chunked at 2048 entries so
one socket-leaking process cannot pin a worker, and the whole resolution is
capped by a 1.5 s budget. On a healthy node it finishes in milliseconds;
on a pathological one the unresolved connections simply keep PID 0 and are
skipped by PID-level checks — container-level checks are unaffected, and a
noisy neighbour can never stall the detection loop.
Zip-bomb inspection: metadata first, never full extraction. Formats with
real headers are read for their declared sizes, essentially for free: zip
central directory (also .jar), gzip's 4-byte ISIZE trailer, tar headers, 7z
and rar file headers. Stream formats without a directory (xz, bzip2) get a
bounded probe — at most 1 MiB compressed in, 10 MiB decompressed out —
and the total is extrapolated from the actual consumed/produced ratio. The
gzip ISIZE field is the uncompressed size mod 2³², so when it reports a
value smaller than the compressed file (a wrap — a real gzip stream never
shrinks its input) the detector falls back to the bounded probe. Archives
under 10 KiB are skipped, each inspection has a 5 s timeout, and sweeps use a
4-worker pool with a 5 m total budget plus a cleared-file mtime cache so an
interrupted sweep resumes where it left off. An archive is flagged when its
ratio ≥ ratio_threshold (150) or its declared uncompressed size ≥
max_uncompressed (50 GiB); severity is high when the ratio is 10× the
threshold or the size ceiling is hit.
The zip-bomb hot trigger is process-group aware. Real extractions are
pipelines: tar xzf burns CPU in the gzip child while the tar parent does
the writes — neither alone trips a per-PID check. So CPU% and write rate are
aggregated per process group; when a group exceeds 80% CPU and 25 MB/s
writes, the archives its members have open (via /proc/<pid>/fd) plus the
archives in their working directories are inspected immediately. Paths are
translated out of container mount namespaces via /proc/<pid>/root. A bomb is
caught mid-unzip in seconds, not after the 30-minute sweep.
On-access pipeline: hash, then YARA. A single fsnotify watcher covers the
upload trees recursively (new directories are added as they appear). Because
writers write in bursts, each path is debounced for settle_ms (500 ms) and
only scanned once quiet, by a pool of 2 workers. The scan is: SHA-256 against
the in-memory blocklist (files ≤ 512 MiB; a hit is critical) — then the
yara CLI against protection.yar, falling back to a recursive scan of the
rules directory (files ≤ 64 MiB, 30 s cap; a hit is high). The event queue
is capped at 1000 entries (oldest dropped with a warning) so a write storm
cannot grow memory, and events are drained into the engine on the next tick.
Intel updates are atomic and independent. rules update (and the 24 h
auto-refresh) downloads the YARA bundle and the MalwareBazaar SHA-256 export —
raw text or zipped, detected by magic bytes — parses, lowercases and
deduplicates the hashes, and writes each artifact with temp-file + rename so
readers never see a partial file. Rules and hashes are fetched independently;
one failing does not block the other. Your custom_hashlist is merged in
memory and never overwritten. Downloads carry a protection/<version>
User-Agent and a 5-minute HTTP timeout.
FIM. Watched files are streamed through SHA-256 (multi-gigabyte files are never loaded into memory). First sighting records a baseline silently; any later content change raises one high-severity event and becomes the new baseline, so each change alerts exactly once.
Engine: dedup, cooldown, timeouts, rate limits. Every event is keyed
(category|detector|target) and suppressed for cooldown; expired keys are
garbage-collected at 2× cooldown. limits.detector_timeout wraps each
detector run in a context that aborts and logs slow detectors instead of
letting them stall the tick. limits.max_alerts_per_minute caps total alert
dispatches. On-access caps its queue; the zipbomb sweep caps workers and
duration; the exploit setuid walk can be capped with max_setuid_walk_files;
cache_directory_mtimes skips unchanged trees.
systemd integration. The unit runs Type=simple with WatchdogSec=60s:
the daemon sends READY=1 at startup and pets WATCHDOG=1 after every scan
tick via a pure-Go sd_notify (no libsystemd), so a hung detection loop gets
the service killed and restarted (Restart=on-failure). The daemon runs as
root — it must read other users' processes — but the unit still applies
ProtectSystem=strict with a restricted ReadWritePaths
(/var/lib/protection, /var/log, Pterodactyl volumes when present),
ProtectHome=read-only, ambient capabilities limited to CAP_SYS_PTRACE,
CAP_KILL, CAP_DAC_READ_SEARCH, and hard resource caps MemoryMax=256M /
CPUQuota=50% — the watchdog can never become the problem it watches for.
Measured footprint. On live nodes: ~13 MB RSS and 1–3% CPU at the default 5 s interval. A synthetic 104K pps outbound flood from inside a container was detected and attributed to that container.
How each detector actually decides — and what keeps it from crying wolf on a game host.
Three independent signals that escalate when combined:
- Signature — binary names are matched against
command the exe basename only (never the full command line, so a shell that merely references a path is not flagged), plus miner argument fingerprints that no legitimate launcher carries (stratum+tcp,--donate-level,--randomx,--algo,--nicehash…). ⇒ high on sight. - Sustained CPU — per-core CPU% from jiffy deltas across ticks must stay
above
cpu_thresholdforsustained_seconds⇒ medium (catches custom / unknown miners). A process burning CPU from a deleted on-disk binary is a classic masked miner ⇒ high. - Pool connection — an established connection to a known mining-pool port ⇒ high.
Signature and sustained high CPU together ⇒ critical.
False-positive design: pool ports are only checked against public remote
IPs — a local redis or HTTP-alt on port 3333 is ignored. Game-server processes
(java, srcds, RustDedicated…) are exempt from the CPU heuristic but not
from signature matching, so a miner actually named xmrig inside a game
container is still caught.
- Container egress rate — docker
statssampled per tick; pps or bps over threshold ⇒ high, attributed to the container and its Pterodactyl server. - Tool signatures — matched on word boundaries, so
loicmatches./loicbutbyte-style fragments inside unrelated flags never trigger. The built-in list deliberately excludes short/generic tokens. - Java flooders —
javaprocesses whose command line containsddos,booter,stresser,flood,doser— the classic game-node booter pattern. - Connection floods — one process holding more than
conn_thresholdsimultaneous outbound connections (TCP established/half-open, or active UDP sockets for amplification floods). Game servers are exempt via the whitelist.
Accumulates distinct destination ports and hosts per process from half-open
(SYN_SENT) connections over the sliding window. Crossing either threshold
⇒ medium; a known scanner binary (nmap, masscan…) ⇒ high on sight.
See Technical design: metadata-first ratio/size checks
across eight archive formats, event-driven hot trigger plus periodic sweep.
Pterodactyl volume paths (/var/lib/pterodactyl/volumes/<uuid>/…) are mapped
back to the owning server UUID so suspend_server and quarantine target the
right customer.
- Known privesc/exploit tools (pwnkit, dirtypipe, linpeas,
nsenter…) ⇒ high. - Reverse shells — network-bound patterns only:
/dev/tcp/,nc -e,socat exec,bash -i >& /dev/tcp… Bare interactive shells are intentionally excluded to avoid false positives. ⇒ high. - Privesc / escape —
chmod +s, sudoers tampering,setcap,nsenter --target 1;nsenterinto PID 1's namespaces from inside a container is a textbook escape ⇒ critical. - Droppers — any process executing from a world-writable scratch dir ⇒ medium; setuid/setgid binaries found in those dirs ⇒ high.
Four signals: known abuse-service binaries or cmdline patterns (Tor flags,
--protocol vmess, ss-server…) ⇒ high; listening sockets on Tor/SOCKS/
proxy ports ⇒ high; and any executable launched from a user upload
directory (miners/proxies uploaded through a panel and run) ⇒ medium.
Game servers are whitelisted from all of it.
SHA-256 blocklist hit ⇒ critical ("Known malware (hash blocklist)"); YARA
rule hit ⇒ high ("Malware detected by YARA rule", rule name in evidence).
Both map to the malware category and the default quarantine_file + alert
rule.
- yara:
yara -r -f <rules_dir> <path>per scan path everyinterval; each match is a high event with the rule name; if the matched file is currently executing, the event is annotated with the process and its container so actions can target it directly. - trivy:
trivy image --severity HIGH,CRITICAL --format jsonper image (images of running containers via the API, elsedocker image ls); one event per affected image — high when a CRITICAL is present, else medium — with counts and the top 5 CVEs. - fim: content change on a watched file ⇒ high, once per change.
- Runs as root. Reading other users'
/procentries, mapping sockets to PIDs, entering container namespaces and killing processes require it (or equivalentlyCAP_SYS_PTRACE,CAP_DAC_READ_SEARCH,CAP_KILLplus Docker socket access). The systemd unit pins exactly those capabilities and sandboxes the rest of the filesystem read-only. - What it reads:
/procprocess metadata and connection tables (host + container namespaces), files under your configured scan/watch paths (archive headers and bounded probes for zip bombs; full contents only for on-access hashing/YARA and FIM hashing), and the Docker unix socket. It writes only to its state dir (/var/lib/protection), its log file, the quarantine dir, and the intel files. - What it sends out: nothing, except (a) threat-intel downloads
(
intel.rules_url,intel.hashlist_url), (b) alerts you configured (Discord webhook, SMTP, your webhook URL), (c) Pterodactyl API calls whensuspend_serveris enabled, and (d) the local Docker socket. There is no telemetry, no phone-home, no license server. Alerts contain host-local details (paths, command lines, IPs) — send them only where you want that data. - Secrets in config: your Discord webhook, SMTP password and Pterodactyl
API key live in
/etc/protection/config.yaml;config initwrites it mode0640(root-readable). Keep it that way. - Quarantine is evidence-preserving: files are moved, not deleted, and
chmod 000'd so they cannot execute; the quarantine dir itself is0700.
docker unreachable at /var/run/docker.sock … container detection/enforcement disabled
Logged at startup when the socket is missing or denied. Host-side detection
keeps working; container rates, attribution and kill_container /
stop_container do not. Check that Docker is running and the daemon runs as
root (or can access the socket). protection status shows the same check
live.
YARA checks silently do nothing. The yara detector and on-access
yara_check shell out to the yara CLI; if it is not installed they are a
permanent no-op by design (the daemon stays CGO-free). Install it
(apt install yara) and run protection rules update so
/etc/protection/yara/protection.yar exists. The periodic yara detector is
also enabled: false by default — turn it on if you want the full-tree sweep.
Trivy does nothing. Same story: install the trivy CLI and set
detectors.trivy.enabled: true. The daemon logs at startup when the binary is
not found.
onaccess: watch path … is not accessible / zipbomb: scan path … is not accessible / exploit: watch path … is not accessible
A configured path does not exist on this node (common on non-Pterodactyl
hosts). The detector skips it and keeps working; remove the path from the
config to silence the warning.
could not load hash blocklist / blocklist has 0 hashes.
Run sudo protection rules update once. Until then, on-access hash checking
simply has an empty blocklist (YARA scanning still works).
Nothing is being killed or quarantined. You are in dry_run: true — the
log line says [dry-run] would run action …. Set dry_run: false and
systemctl restart protection to arm.
Too many / too few alerts. Tune per-channel min_severity, raise
general.cooldown, enable alerts.batch, or set
limits.max_alerts_per_minute. To see everything the detectors produce
without cooldowns, run sudo protection scan.
Config errors at startup. Run protection config check — it prints the
exact problem (e.g. discord alert enabled but webhook_url not set).
Verifying network visibility. protection debug-conns lists every
connection the daemon sees, host and container, with owning PID and container
ID — confirm a suspicious connection appears there before wondering why it was
not flagged. Add a port number to filter.
Debugging detection decisions. Set general.log_level: debug and watch
journalctl -u protection -f.
Requires Go ≥ 1.22 (Linux; CGO is disabled — the binary is fully static).
make build # -> bin/protection (static, trimmed, version-stamped)
make test # unit tests across config, engine, detectors, actions, intel, system, core
make vet # go vet
make fmt # gofmt the tree
make run # build + one-off scan against the default config
sudo make install # binary + systemd unit (PREFIX, DESTDIR supported)
make uninstall # remove installed files
make clean # remove bin/Version is injected at build time from git
(git describe --tags --always --dirty, falling back to 1.0.0) into both
the CLI and the intel User-Agent.
Cross-compile for ARM nodes:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o protection-linux-arm64 ./cmd/protectionCI. Every push/PR to main runs gofmt verification, go vet,
go test ./... and go build ./... (.github/workflows/ci.yml). Pushing a
v* tag builds static linux/amd64 and linux/arm64 binaries and attaches them
to a GitHub Release (.github/workflows/release.yml) — the artifacts the
installer downloads.
Layout:
cmd/protection CLI entrypoint + embedded starter config
internal/core Event, Severity, Category (shared leaf package)
internal/config YAML config + defaults + validation
internal/system /proc + network introspection, sd_notify (pure Go)
internal/docker tiny Docker Engine API client over the unix socket
internal/detectors miner, portscan, ddos, zipbomb, exploit, abuse, yara, fim, trivy, onaccess
internal/intel rules + hash blocklist download/refresh (atomic)
internal/alerts discord, smtp, webhook
internal/actions docker, file, pterodactyl, process, neutralize
internal/engine scheduler, snapshot fan-out, dedup, rule matching, batching
rules/protection.yar curated YARA bundle served by `rules update`
packaging/ systemd unit
Adding a detector: implement detectors.Detector
(Name() string + Run(ctx, *system.Snapshot) ([]core.Event, error)),
register it in buildDetectors in cmd/protection/main.go, and add its
config block to internal/config. Detectors receive the shared snapshot, must
return within roughly one scan interval, and should treat every error as
non-fatal (log and skip) so one broken detector never blinds the rest.