Watches SSH login attempts on your VPS and posts a Discord notification for
every success/failure, including the source IP (and optional GeoIP location).
After too many failed attempts from the same IP within a time window, it can
automatically block that IP using whatever firewall you already have running
(ufw, firewalld, nftables, or iptables). It can also send periodic
Discord summary reports on a cron schedule, and ping @everyone, a role, or
a specific user on any notification type.
| File | Purpose |
|---|---|
/etc/ssh-guard/config.conf |
Shared settings: webhook URL, thresholds, mentions, report schedule |
/usr/local/bin/ssh-discord-notify.sh |
Tails the SSH journal, sends Discord embeds, triggers auto-block |
/usr/local/bin/block-ip.sh |
Manual/automatic IP block, unblock, list, status |
/usr/local/bin/ssh-guard-report.sh |
Sends the periodic Discord summary report |
/etc/systemd/system/ssh-discord-notify.service |
Keeps the notifier running and restarts it on crash/reboot |
/etc/cron.d/ssh-guard-report |
Cron schedule for the periodic report (self-gates on REPORT_ENABLED) |
/etc/ssh-guard/blocked_ips.list |
Record of currently blocked IPs and why |
/var/lib/ssh-guard/fail_attempts.log |
Rolling window of recent failed-login timestamps per IP (used for auto-block) |
/var/lib/ssh-guard/lifetime_fail_counts.tsv |
All-time failed-login count per IP, never pruned |
/var/lib/ssh-guard/counter_*.count |
Running totals (failed password, invalid user, preauth, probes, blocks) used to compute report deltas |
/var/lib/ssh-guard/report_baseline.tsv |
Snapshot of the counters above as of the last report, so each report shows only what happened since then |
- A systemd-based Linux distro (Debian, Ubuntu, RHEL/CentOS/Fedora/Rocky, etc.)
curl,systemctl,journalctl(present on virtually all modern distros)- One of:
ufw,firewalld,nftables, oriptables— for auto-block/block-ip.shto work. The notifier itself works without any of these. - A cron daemon (
cron/crond) if you want periodic reports to run automatically — otherwise you can still runssh-guard-report.shmanually - A Discord webhook URL (Server Settings → Integrations → Webhooks → New Webhook)
Host install-ssh-guard.sh in your repo, then on the VPS run it as root:
curl -fsSL https://raw.githubusercontent.com/pjortiz/ssh-guard/refs/heads/main/install-ssh-guard.sh | sudo bashYou'll be prompted for:
- Your Discord webhook URL
- Whether to include GeoIP lookups in notifications
- Whether to auto-block IPs after repeated failures, and the threshold/window
- Whether to send a Discord message for every individual failed attempt, or stay quiet until a block actually happens
- Whether to send a Discord message when an IP gets blocked
- A default
@mentiontarget applied to all notification types (none/everyone/role:<id>/user:<id>) — see Mentions below for fine-tuning this per notification type - Whether to enable periodic summary reports, and their cron schedule
Useful for provisioning scripts, Ansible, cloud-init, etc.:
curl -fsSL https://raw.githubusercontent.com/pjortiz/ssh-guard/refs/heads/main/install-ssh-guard.sh | sudo \
WEBHOOK_URL="https://discord.com/api/webhooks/xxx/yyy" \
DO_GEOIP=true \
AUTO_BLOCK=true \
FAIL_THRESHOLD=5 \
FAIL_WINDOW=600 \
NOTIFY_FAILED_ATTEMPTS=true \
NOTIFY_ON_BLOCK=true \
MENTION_DEFAULT=none \
REPORT_ENABLED=false \
REPORT_CRON="0 8 * * *" \
bashAny variable you export ahead of time is used as-is; anything left unset falls
back to an interactive prompt (read from /dev/tty, so this works fine even
though stdin is occupied by the piped script) or its default.
The installer also:
- Auto-detects whether your distro's sshd unit is
sshorsshd - Validates the webhook URL looks legitimate before writing anything
- Enables and starts the systemd service, and confirms it actually started
- Installs the cron schedule for reports (harmless even if reports are disabled — the report script checks
REPORT_ENABLEDitself and no-ops) - Sends a one-time "installed" test message to Discord
⚠️ SSH Login Succeeded — host, user, source IP, location. Uses a warning icon and orange color, not a green checkmark — on purpose. A successful login is exactly the event you want to notice immediately: it might be you, or it might be an intrusion. Always sent regardless ofNOTIFY_FAILED_ATTEMPTS.- ❌ SSH Login Failed — host, user, source IP, location, the current attempt count within the failure window, and the all-time (lifetime) failed-attempt count for that IP. Fires for both a wrong password on a real account and a login attempt against a username that doesn't exist at all (
Invalid user). Suppressed ifNOTIFY_FAILED_ATTEMPTS=false, unless that IP's lifetime failure count already exceedsFAIL_THRESHOLD— repeat offenders always get surfaced. ⚠️ SSH Connection Closed (preauth) — the client attempted a real username, then disconnected before completing authentication. Same suppression rule and repeat-offender exception as above.- 🚫 IP Blocked — fired by
block-ip.sh, whether triggered manually or automatically. Can be turned off withNOTIFY_ON_BLOCK=falseif you'd rather the block just happen silently (the IP still gets blocked either way — this only controls the notification). - ✅ IP Unblocked — fired by
block-ip.sh remove. Always sent. - 📊 SSH Guard Report — a periodic summary, if
REPORT_ENABLED=true. See Periodic reports below.
Most raw internet scanning traffic hits your server, sends nothing at all
(not even a username), and disconnects — sshd logs this as a plain
Connection closed by <ip> port <port> [preauth] with no authenticating user in it. This is genuinely not a login attempt, so it's never
notified individually (it would be constant noise from every scanner on
the internet) — but it is counted, and shows up as "Unauthenticated
probes" in the periodic report so you can still see the scale of scan
traffic hitting the box. If you were expecting to see a preauth alert for
every disconnected connection and aren't, this is almost always why.
Every notification type can optionally ping @everyone, a specific role, or
a specific user. Configure this per type in /etc/ssh-guard/config.conf:
| Setting | Controls |
|---|---|
MENTION_ON_FAILURE |
Failed login / invalid user / preauth-close notifications |
MENTION_ON_SUCCESS |
Successful login notifications |
MENTION_ON_BLOCK |
IP Blocked notifications |
MENTION_ON_UNBLOCK |
IP Unblocked notifications |
MENTION_ON_REPORT |
Periodic report notifications |
Each accepts one of:
none(default) — no mentioneveryone— pings@everyonerole:<role_id>— pings a specific Discord role, e.g.role:123456789012345678user:<user_id>— pings a specific Discord user, e.g.user:987654321098765432
To find a role or user ID, enable Developer Mode in Discord (User Settings → Advanced), then right-click the role or user and choose "Copy ID".
The installer's MENTION_DEFAULT prompt sets all five of these to the same
value for convenience — edit config.conf directly afterward if you want,
say, @everyone on blocks but nothing on routine failures. Note:
rerunning the installer will overwrite all five with whatever you answer at
that prompt, even if you'd customized them individually — so if you've
fine-tuned per-type mentions, skip the reinstall and just edit config.conf
by hand instead.
Set REPORT_ENABLED=true (via the installer prompt or directly in
config.conf) to get a recurring Discord summary on the schedule in
REPORT_CRON (standard 5-field cron syntax, default 0 8 * * * — daily at
8am server time). Each report shows counts since the previous report
(not an ever-growing all-time total), plus a point-in-time snapshot of
every currently-blocked IP and why:
- Failed password attempts
- Invalid user attempts
- Preauth disconnects
- Unauthenticated probes
- New IPs blocked
- Total IPs currently blocked
- The list of currently blocked IPs, each with its block reason (truncated with a pointer to
block-ip.sh listif it gets too long for a single Discord field)
You can run it manually any time, regardless of the cron schedule or even if reports are disabled entirely for testing:
sudo ssh-guard-report.shNote this will exit silently and do nothing if REPORT_ENABLED isn't true
in config.conf — that's intentional, so the cron job can stay installed
permanently without needing to be added/removed as you toggle the setting.
To change the schedule after install, either edit
/etc/cron.d/ssh-guard-report directly (the schedule is the first 5 fields
of the last line), or re-run the installer with a new REPORT_CRON value.
Edit /etc/ssh-guard/config.conf, then restart the service:
sudo nano /etc/ssh-guard/config.conf
sudo systemctl restart ssh-discord-notify| Setting | Default | Description |
|---|---|---|
WEBHOOK_URL |
— | Your Discord webhook URL |
SSHD_UNIT |
ssh |
systemd unit name for sshd (ssh or sshd) |
DO_GEOIP |
true |
Look up rough city/region/country per IP via ip-api.com |
AUTO_BLOCK |
true |
Automatically block an IP after too many failures |
FAIL_THRESHOLD |
5 |
Failed attempts (per IP) before auto-block fires |
FAIL_WINDOW |
600 |
Time window in seconds for the above |
NOTIFY_FAILED_ATTEMPTS |
true |
Send a Discord message for every individual failed/preauth attempt. Set to false to stay quiet on each attempt while still counting them, tracking the lifetime counter, and auto-blocking — you'll just get the single "IP Blocked" message once a block actually happens. Exception: once an IP's lifetime failed-attempt count exceeds FAIL_THRESHOLD, per-attempt notifications for that IP always fire regardless of this setting — see below. |
NOTIFY_ON_BLOCK |
true |
Send a Discord message when an IP gets blocked. The block itself always happens either way — this only controls the notification. |
NOTIFY_ON_UNBLOCK |
true |
Send a Discord message when an IP gets unblocked. |
MENTION_ON_FAILURE / MENTION_ON_SUCCESS / MENTION_ON_BLOCK / MENTION_ON_UNBLOCK / MENTION_ON_REPORT |
none |
@mention target per notification type — see Mentions above. |
REPORT_ENABLED |
false |
Send periodic Discord summary reports — see Periodic reports above. |
REPORT_CRON |
0 8 * * * |
Cron schedule for the report. |
This file contains your webhook URL, so it's created with chmod 600 (root-only read). Keep it that way.
NOTIFY_FAILED_ATTEMPTS=false is meant to quiet down routine scanner noise,
not hide a genuinely persistent attacker. So there's one built-in override:
once a given IP's lifetime failed-attempt count exceeds FAIL_THRESHOLD,
every subsequent failed/preauth notification from that IP fires regardless of
NOTIFY_FAILED_ATTEMPTS. This matters most if you run with AUTO_BLOCK=false
(so the IP is never actually blocked) — without this override, a determined
attacker could keep retrying indefinitely in total silence.
sudo block-ip.sh add 203.0.113.7 "repeated failed SSH logins"
sudo block-ip.sh remove 203.0.113.7
sudo block-ip.sh list
sudo block-ip.sh status 203.0.113.7
sudo block-ip.sh sync # reapply every recorded IP to the live firewall
sudo block-ip.sh config # show current settings + firewall backend + block count
sudo block-ip.sh rules # show the ACTUAL live firewall rules blocking IPsblock-ip.sh works standalone too — if /etc/ssh-guard/config.conf doesn't
exist or has no WEBHOOK_URL, it just skips the Discord notification and
blocks/unblocks silently.
sudo block-ip.sh config shows the current settings (including mentions,
notification toggles, and report settings), active firewall backend, and how
many IPs are currently recorded as blocked — the webhook URL is partially
masked in the output, since it's a secret.
sudo block-ip.sh rules shows the actual, live rules from the firewall
itself (not this script's record file) — the real source of truth for what's
currently being dropped. Useful for confirming a block really took effect, or
spotting drift between what list reports and what the firewall is actually
doing:
- ufw →
ufw status numbered - firewalld → the active zone's rich rules
- nftables → the
blocked_ipsset and its containing table - iptables → the
DROPrules in theINPUTchain
block-ip.sh status <ip> also prints the all-time failed-login count for that
IP if one exists in /var/lib/ssh-guard/lifetime_fail_counts.tsv, even if the
IP isn't currently blocked.
Rule persistence depends on your firewall backend:
- ufw / firewalld — rules persist automatically.
- nftables — save your ruleset explicitly, e.g.
nft list ruleset > /etc/nftables.conf(path/mechanism varies by distro), and make sure it's loaded at boot. - iptables — rules do not survive a reboot on their own. Install
iptables-persistent(Debian/Ubuntu:apt install iptables-persistent) and runnetfilter-persistent saveafter blocking, or use a cron/systemd hook to persist on change.
# Live logs
sudo journalctl -u ssh-discord-notify -f
# Restart after editing config
sudo systemctl restart ssh-discord-notify
# Check service status
sudo systemctl status ssh-discord-notify
# Send a report right now, ignoring the cron schedule
sudo ssh-guard-report.shRerunning install-ssh-guard.sh is safe and won't disrupt existing state:
/usr/local/bin/ssh-discord-notify.sh,block-ip.sh, andssh-guard-report.share overwritten cleanly (no duplication)./etc/ssh-guard/blocked_ips.list,/var/lib/ssh-guard/fail_attempts.log,/var/lib/ssh-guard/lifetime_fail_counts.tsv, the report counters, and the report baseline are never touched — block history, lifetime counts, and report period tracking all survive.- If a config already exists at
/etc/ssh-guard/config.conf, its current values (webhook URL, thresholds, etc.) are offered as the defaults in each prompt — hit Enter to keep them, or type a new value to change just that one setting. - Exception: the
MENTION_DEFAULTprompt always overwrites all fiveMENTION_ON_*settings together — see Mentions above.
Firewall rule persistence isn't always guaranteed by the firewall itself — most
notably, plain iptables rules do not survive a reboot without
iptables-persistent installed and configured (see the note above). Without
reconciliation, blocked_ips.list could end up saying an IP is blocked when
the actual firewall no longer has that rule.
To prevent that, the systemd unit runs block-ip.sh sync automatically via
ExecStartPre every time the service starts — on boot, on
systemctl restart, and after a crash-triggered restart — not just during
install. It reapplies every IP in blocked_ips.list to the live firewall, so
recorded state and actual firewall state can't silently drift apart for long.
This step is best-effort (prefixed with - in the unit file) so a sync
failure never prevents the notifier itself from starting.
You can also run it manually any time:
sudo block-ip.sh syncsudo systemctl disable --now ssh-discord-notify
sudo rm -f /usr/local/bin/ssh-discord-notify.sh /usr/local/bin/block-ip.sh \
/usr/local/bin/ssh-guard-report.sh /etc/systemd/system/ssh-discord-notify.service \
/etc/cron.d/ssh-guard-report
sudo rm -rf /etc/ssh-guard /var/lib/ssh-guard
sudo systemctl daemon-reloadNote: this removes the block-ip record and config, but does not automatically
remove any IPs you've already blocked from your firewall — unblock them first
with sudo block-ip.sh remove <ip> if you want them cleared, or manage that
firewall's rules directly afterward.
- The notifier tails
journalctl -fu <SSHD_UNIT>rather than parsing/var/log/auth.logdirectly, so it works the same way across distros that route sshd logs through systemd's journal. - It matches on the log lines sshd already emits (
Accepted ... from <ip>,Failed password ... from <ip>,Invalid user ... from <ip>) rather than hooking into PAM, which keeps it simple and catches password auth, public-key auth, and invalid-username rejections alike. - Failed attempts are recorded per-IP with a timestamp in
/var/lib/ssh-guard/fail_attempts.log; on each new failure, entries older thanFAIL_WINDOWare pruned and the remaining count for that IP is checked againstFAIL_THRESHOLD. - Separately, a simple all-time counter per IP is kept in
/var/lib/ssh-guard/lifetime_fail_counts.tsv(never pruned) so you can see whether an IP is a first-time offender or a repeat one, even outside the current auto-block window. - A second, independent set of global counters (
/var/lib/ssh-guard/counter_*.count) tracks aggregate totals — not per-IP — purely to feed the periodic report. These increment regardless ofNOTIFY_FAILED_ATTEMPTS, so the report stays accurate even when individual notifications are muted. - When the threshold is hit, the notifier calls
block-ip.sh add <ip> "...", which is idempotent (blocking an already-blocked IP is a no-op) and handles sending its own Discord notification for the block event (subject toNOTIFY_ON_BLOCK).
/etc/ssh-guard/config.confcontains your webhook URL in plaintext — it'schmod 600by default; don't loosen that.- Auto-blocking is based on log pattern matching, not authentication
internals — review
FAIL_THRESHOLDcarefully if you have automated systems/scripts that legitimately retry SSH connections, to avoid locking yourself out. - Always keep at least one root/console access path (VPS provider's web console, a separate admin IP allowlist, etc.) in case you accidentally block your own IP.
- If you use
MENTION_ON_*=everyone, make sure the webhook's channel actually permits@everyonementions for the account that owns the webhook — otherwise Discord will silently deliver the message without the ping actually notifying anyone.