Skip to content

SSH login notifications

An unnoticed SSH login as root is the worst thing that can happen to a server. Let’s make it so that every successful login (and optionally — attempts) is sent to you in Notifly with the user, IP and time.

PAM can call an arbitrary script after successful authentication.

Save as /usr/local/bin/notifly-ssh-login:

#!/usr/bin/env bash
set -eu
set -a; source /etc/notifly.env; set +a
# PAM передаёт переменные окружения PAM_*
USER="${PAM_USER:-unknown}"
RHOST="${PAM_RHOST:-unknown}"
SERVICE="${PAM_SERVICE:-unknown}"
TYPE="${PAM_TYPE:-unknown}"
# Only react to sshd session openings
[ "$SERVICE" = "sshd" ] || exit 0
[ "$TYPE" = "open_session" ] || exit 0
HOST=$(hostname -s)
PRIO=5
[ "$USER" = "root" ] && PRIO=9
/usr/local/bin/notifly-send \
"🔐 SSH-вход: $USER@$HOST" \
"Пользователь: $USER
Источник: $RHOST
Сервер: $HOST
Время: $(date '+%Y-%m-%d %H:%M:%S %Z')" \
"$PRIO" || true

Make it executable:

Окно терминала
sudo chmod +x /usr/local/bin/notifly-ssh-login

Open /etc/pam.d/sshd and add at the very end:

session optional pam_exec.so /usr/local/bin/notifly-ssh-login

Done. No need to restart sshd — PAM config is re-read on every connection.

From another machine:

Окно терминала
ssh user@your-server

Notifly will receive a message with priority 5 (or 9 for root).

pam_exec only triggers on success. To track failed attempts it’s easier to read journalctl via systemd. Create a service watcher:

/usr/local/bin/notifly-ssh-failed:

#!/usr/bin/env bash
set -eu
set -a; source /etc/notifly.env; set +a
HOST=$(hostname -s)
journalctl -u ssh -u sshd -f -o cat --since now | \
while read -r line; do
if echo "$line" | grep -q "Failed password"; then
USER=$(echo "$line" | grep -oP 'for \K\S+' | head -1)
IP=$(echo "$line" | grep -oP 'from \K\S+' | head -1)
/usr/local/bin/notifly-send \
"⚠️ Неудачный SSH: $USER@$HOST" \
"IP: $IP
Пользователь: $USER" 4 || true
fi
done

/etc/systemd/system/notifly-ssh-failed.service:

[Unit]
Description=Notify about failed SSH attempts
After=ssh.service network-online.target
[Service]
ExecStart=/usr/local/bin/notifly-ssh-failed
Restart=always
RestartSec=10s
[Install]
WantedBy=multi-user.target
Окно терминала
sudo chmod +x /usr/local/bin/notifly-ssh-failed
sudo systemctl daemon-reload
sudo systemctl enable --now notifly-ssh-failed

Windows: RDP / WinRM / SSH via the Security Event Log

Section titled “Windows: RDP / WinRM / SSH via the Security Event Log”

The Windows-server equivalent. All logons — interactive, RDP, WinRM, OpenSSH — are recorded in the Security Event Log. Relevant codes:

  • 4624 — successful logon (LogonType=10 — RDP, 5 — service, 3 — network, 2 — local).
  • 4625 — failed logon.
C:\scripts\Notifly-Logon.ps1
param([int]$EventId = 4624)
. C:\scripts\Notifly.ps1
# Get the latest event from the Security Log
$ev = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$EventId} -MaxEvents 1
if (-not $ev) { return }
# Parse EventData
$xml = [xml]$ev.ToXml()
$data = @{}
$xml.Event.EventData.Data | ForEach-Object { $data[$_.Name] = $_.'#text' }
$user = $data.TargetUserName
$logonType = $data.LogonType
$ip = $data.IpAddress
$workstation = $data.WorkstationName
# Ignore service logons and anonymous
if ($user -in @("SYSTEM", "ANONYMOUS LOGON", "$env:COMPUTERNAME$")) { return }
if ($user -like "*$") { return } # computer accounts
$logonName = switch ($logonType) {
"2" { "Локальный" }
"3" { "Сеть" }
"7" { "Разблокировка" }
"10" { "RDP" }
"11" { "Кэшированный" }
default { "Type=$logonType" }
}
$prio = if ($EventId -eq 4625) { 5 } else { 5 }
if ($user -in @("Administrator", "Администратор")) { $prio = 9 }
$icon = if ($EventId -eq 4625) { "⚠️ Неудачный" } else { "🔐 Вход" }
Send-Notifly `
-Title "$icon $($logonName): $user@$env:COMPUTERNAME" `
-Message "Пользователь: $user`nИсточник: $ip $workstation`nСервер: $env:COMPUTERNAME`nВремя: $($ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))" `
-Priority $prio
Окно терминала
# Event 4624 — all successful logons
$xml = @"
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[EventID=4624 and (EventData/Data[@Name='LogonType']='10' or EventData/Data[@Name='LogonType']='2' or EventData/Data[@Name='LogonType']='7')]]
</Select>
</Query>
</QueryList>
"@
$Trigger = New-ScheduledTaskTrigger -AtStartup
$Trigger.Subscription = $xml
$Action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Notifly-Logon.ps1 -EventId 4624"
Register-ScheduledTask -TaskName "Notifly Logon Watch" -Trigger $Trigger -Action $Action `
-Principal (New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest)
# Failed attempts (Event ID 4625)
$xml2 = $xml -replace '4624', '4625' -replace "\(EventData.*\)", "true"
$T2 = New-ScheduledTaskTrigger -AtStartup
$T2.Subscription = $xml2
$A2 = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Notifly-Logon.ps1 -EventId 4625"
Register-ScheduledTask -TaskName "Notifly Failed Logon Watch" -Trigger $T2 -Action $A2 `
-Principal (New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest)
  • Every successful logon confirms: it was you. If you get a notification about someone’s logon at 3 AM from another country — that’s a signal.
  • Root always has priority 9 — a separate loud alert on your phone, even if you’re sleeping.
  • Failed attempts become a live indicator of bruteforce: if the stream runs non-stop — it’s time to set up Fail2ban or tighten MaxAuthTries.
  • Add IP geolocation via geoiplookup — header «🌍 Logon from RU/Moscow».
  • Do not send notifications for an allowlist of IPs (your office/VPN).
  • Integrate with Fail2ban — receive ban events as well.