Skip to content
81 changes: 9 additions & 72 deletions bin/seclog
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,16 @@

FAIL_LOOKBACK="${1:-24 hours ago}"

C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[34m'; C_OFF=$'\033[0m'; C_BLD=$'\033[1m'
SECLOG_LIB="$(dirname "$(readlink -f "$0")")/seclog-lib.sh"
[ -f "$SECLOG_LIB" ] || SECLOG_LIB="$HOME/.local/bin/seclog-lib.sh"
# shellcheck source=seclog-lib.sh
. "$SECLOG_LIB"

# Returns country string for an IP, or [LAN] for private ranges.
# Requires: geoip-bin + geoip-database (apt install geoip-bin geoip-database)
geo_lookup() {
local ip="$1" second
# Strip IPv6-mapped IPv4: [::ffff:1.2.3.4] -> 1.2.3.4
ip="${ip#\[}"; ip="${ip%\]}"; ip="${ip#::ffff:}"; ip="${ip#::FFFF:}"
case "$ip" in
127.*|10.*|169.254.*|::1|fe80*) echo "[LAN]"; return ;;
192.168.*) echo "[LAN]"; return ;;
172.*)
second=$(printf '%s' "$ip" | cut -d. -f2)
{ [ "$second" -ge 16 ] && [ "$second" -le 31 ]; } 2>/dev/null && { echo "[LAN]"; return; }
;;
esac
command -v geoiplookup >/dev/null 2>&1 || return
geoiplookup "$ip" 2>/dev/null \
| awk -F': ' '/Country Edition/ { sub(/^[[:space:]]+/,"",$2); print $2; exit }'
}

# ── 1) All active connections (grouped by app + target) ──
_CONNS_RAW=$(ss -tnp 2>/dev/null | awk '
$1 == "ESTAB" {
local = $4; peer = $5
pname = "-"
rest = ""; for (i = 6; i <= NF; i++) rest = rest $i
if (match(rest, /"[^"]+"/)) pname = substr(rest, RSTART+1, RLENGTH-2)
nl = split(local, la, ":"); lport = la[nl]+0
np = split(peer, pa, ":"); pport = pa[np]+0
dir = (lport <= pport) ? "IN" : "OUT"
# Group key: IN groups by peer_ip + lport + app; OUT groups by remote host:port + app
if (dir == "IN") {
peer_ip = peer; sub(/:[^:]+$/, "", peer_ip)
key = dir "|" peer_ip "|" lport "|" pname
} else {
key = dir "|" peer "|" pname
}
count[key]++
}
END { for (k in count) printf "%s|%d\n", k, count[k] }' \
| sort -t'|' -k1,1r -k2,2)

ACTIVE_COUNT=$(ss -tnp 2>/dev/null | grep -c ESTAB)

echo "$C_BLD$C_BLU── All active connections ($ACTIVE_COUNT) ──$C_OFF"
if [ -n "$_CONNS_RAW" ]; then
while IFS='|' read -r dir a b c cnt; do
[ -z "$dir" ] && continue
if [ "$dir" = "IN" ]; then
# a=peer_ip b=lport c=pname
geo=$(geo_lookup "$a")
users=$(who 2>/dev/null | grep -F "($a)" | awk '{print $1}' | sort -u | paste -sd',' -)
echo " [IN ] $a -> :$b"
echo " app: $c | ${geo:-(unknown)}${users:+ | $users}"
else
# a=peer_host:port b=pname c=cnt(shifted) — re-read
# When OUT: fields are dir|peer|pname|cnt → a=peer b=pname c=cnt
peer_ip="${a%%:*}"
geo=$(geo_lookup "$peer_ip")
n="${c:-1}"
echo " [OUT] $a"
printf ' app: %s | %s%s\n' "$b" "${geo:-(unknown)}" "$([ "$n" -gt 1 ] 2>/dev/null && echo " (${n}x)")"
fi
echo
done <<< "$_CONNS_RAW"
else
echo " (none)"
echo
fi
# ── 1) All active connections ──
show_active_connections

# ── 2) Last 5 successful logins ──
echo "$C_BLD$C_GRN── Last 5 successful logins (distinct IPs) ──$C_OFF"
echo "${C_BLD}${C_GRN}── Last 5 successful logins (distinct IPs) ──${C_OFF}"
journalctl --no-pager -r _COMM=sshd-session 2>/dev/null | awk '
/Accepted/ {
for (i=1; i<=NF; i++) if ($i=="from") {
Expand Down Expand Up @@ -113,8 +50,8 @@ FAIL_SUMMARY=$(journalctl --no-pager --since "$FAIL_LOOKBACK" _COMM=sshd-session
if [ -n "$FAIL_SUMMARY" ]; then
FAIL_LINES=$(echo "$FAIL_SUMMARY" | wc -l)
TOTAL_FAILS=$(echo "$FAIL_SUMMARY" | awk -F'|' '{s+=$1} END{print s}')
echo "$C_BLD$C_RED── ⚠ Failed SSH attempts ($FAIL_LOOKBACK): $TOTAL_FAILS from $FAIL_LINES IP(s) ──$C_OFF"
echo "${C_BLD}${C_RED}── ⚠ Failed SSH attempts ($FAIL_LOOKBACK): $TOTAL_FAILS from $FAIL_LINES IP(s) ──${C_OFF}"
echo "$FAIL_SUMMARY" | awk -F'|' '{printf " %3dx %-16s %-20s user=%s\n", $1, $2, $3, $4}'
else
echo "$C_BLD$C_YEL── Failed SSH attempts ($FAIL_LOOKBACK): none ──$C_OFF"
echo "${C_BLD}${C_YEL}── Failed SSH attempts ($FAIL_LOOKBACK): none ──${C_OFF}"
fi
82 changes: 82 additions & 0 deletions bin/seclog-diagnose
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/bin/bash
# seclog-diagnose — check that all seclog-linux dependencies and config are healthy.

CONFIG="${SSH_NOTIFY_CONFIG:-$HOME/.config/seclog-linux/config}"
[ -f "$CONFIG" ] && . "$CONFIG"

PASS=0; WARN=0; FAIL=0

ok() { echo " [OK ] $*"; PASS=$((PASS+1)); }
warn() { echo " [WARN] $*"; WARN=$((WARN+1)); }
fail() { echo " [FAIL] $*"; FAIL=$((FAIL+1)); }

echo "── seclog-linux diagnostics ──"
echo

echo "[ Prerequisites ]"
for cmd in bash curl awk ss who getent timeout journalctl git; do
command -v "$cmd" >/dev/null 2>&1 && ok "$cmd found" || fail "$cmd not found"
done
command -v geoiplookup >/dev/null 2>&1 \
&& ok "geoiplookup found (geo-location active)" \
|| warn "geoiplookup not found — install: sudo apt install geoip-bin geoip-database"
echo

echo "[ Config ]"
if [ -f "$CONFIG" ]; then
ok "config found: $CONFIG"
else
fail "config missing: $CONFIG — run install.sh first"
fi

if [ -z "${NTFY_URL:-}" ] || [ "$NTFY_URL" = "http://YOUR_NTFY_HOST:2586/YOUR_TOPIC" ]; then
fail "NTFY_URL not configured in $CONFIG"
else
ok "NTFY_URL set: $NTFY_URL"
fi
echo

echo "[ Network ]"
if [ -n "${NTFY_URL:-}" ] && [ "$NTFY_URL" != "http://YOUR_NTFY_HOST:2586/YOUR_TOPIC" ]; then
_AUTH=${NTFY_TOKEN:+-H "Authorization: Bearer $NTFY_TOKEN"}
if curl -fsS -m 5 ${_AUTH:+"$_AUTH"} -d "seclog-diagnose test" "$NTFY_URL" >/dev/null 2>&1; then
ok "ntfy reachable and accepted test push"
else
fail "ntfy unreachable or auth failed — check NTFY_URL and NTFY_TOKEN"
fi
else
warn "skipping ntfy connectivity check (NTFY_URL not configured)"
fi
echo

echo "[ Permissions ]"
if id -nG 2>/dev/null | grep -qw systemd-journal; then
ok "user is in systemd-journal group"
else
warn "user not in systemd-journal — SSH log history may be empty"
warn " fix: sudo usermod -aG systemd-journal \$USER (then re-login)"
fi

case ":$PATH:" in
*":$HOME/.local/bin:"*) ok "\$HOME/.local/bin is in PATH" ;;
*) warn "\$HOME/.local/bin is NOT in PATH — add to .profile: export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
esac
echo

echo "[ Systemd service ]"
if systemctl --user is-active seclog-linux-fail-monitor.service >/dev/null 2>&1; then
ok "seclog-linux-fail-monitor.service is active"
else
fail "seclog-linux-fail-monitor.service is NOT running"
echo " run: systemctl --user status seclog-linux-fail-monitor"
fi

if systemctl --user is-enabled seclog-linux-fail-monitor.service >/dev/null 2>&1; then
ok "seclog-linux-fail-monitor.service is enabled"
else
warn "seclog-linux-fail-monitor.service is not enabled (won't auto-start)"
fi
echo

echo "── Result: $PASS ok, $WARN warnings, $FAIL failures ──"
[ "$FAIL" -gt 0 ] && exit 1 || exit 0
79 changes: 79 additions & 0 deletions bin/seclog-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/bin/bash
# Shared helpers sourced by seclog and ssh-login-notify.sh.

C_RED=$'\033[31m'
C_GRN=$'\033[32m'
C_YEL=$'\033[33m'
C_BLU=$'\033[34m'
C_OFF=$'\033[0m'
C_BLD=$'\033[1m'

# Returns country string for an IP, or [LAN] for private ranges.
# Requires: geoip-bin + geoip-database (apt install geoip-bin geoip-database)
geo_lookup() {
local ip="$1" second
# Strip IPv6-mapped IPv4: [::ffff:1.2.3.4] -> 1.2.3.4
ip="${ip#\[}"; ip="${ip%\]}"; ip="${ip#::ffff:}"; ip="${ip#::FFFF:}"
case "$ip" in
127.*|10.*|169.254.*|::1|fe80*) echo "[LAN]"; return ;;
192.168.*) echo "[LAN]"; return ;;
172.*)
second=$(printf '%s' "$ip" | cut -d. -f2)
{ [ "$second" -ge 16 ] && [ "$second" -le 31 ]; } 2>/dev/null && { echo "[LAN]"; return; }
;;
esac
command -v geoiplookup >/dev/null 2>&1 || return
geoiplookup "$ip" 2>/dev/null \
| awk -F': ' '/Country Edition/ { sub(/^[[:space:]]+/,"",$2); print $2; exit }'
}

# Prints grouped all-active-connections block to stdout.
# Requires: ss, who, geo_lookup
show_active_connections() {
local _CONNS_RAW ACTIVE_COUNT

_CONNS_RAW=$(ss -tnp 2>/dev/null | awk '
$1 == "ESTAB" {
local = $4; peer = $5
pname = "-"
rest = ""; for (i = 6; i <= NF; i++) rest = rest $i
if (match(rest, /"[^"]+"/)) pname = substr(rest, RSTART+1, RLENGTH-2)
nl = split(local, la, ":"); lport = la[nl]+0
np = split(peer, pa, ":"); pport = pa[np]+0
dir = (lport <= pport) ? "IN" : "OUT"
if (dir == "IN") {
peer_ip = peer; sub(/:[^:]+$/, "", peer_ip)
key = dir "|" peer_ip "|" lport "|" pname
} else {
key = dir "|" peer "|" pname
}
count[key]++
}
END { for (k in count) printf "%s|%d\n", k, count[k] }' \
| sort -t'|' -k1,1r -k2,2)

ACTIVE_COUNT=$(ss -tnp 2>/dev/null | grep -c ESTAB)

printf '%s\n' "${C_BLD}${C_BLU}── All active connections ($ACTIVE_COUNT) ──${C_OFF}"
if [ -n "$_CONNS_RAW" ]; then
while IFS='|' read -r dir a b c cnt; do
[ -z "$dir" ] && continue
if [ "$dir" = "IN" ]; then
geo=$(geo_lookup "$a")
users=$(who 2>/dev/null | grep -F "($a)" | awk '{print $1}' | sort -u | paste -sd',' -)
echo " [IN ] $a -> :$b"
echo " app: $c | ${geo:-(unknown)}${users:+ | $users}"
else
geo=$(geo_lookup "${a%%:*}")
n="${c:-1}"
echo " [OUT] $a"
printf ' app: %s | %s%s\n' "$b" "${geo:-(unknown)}" \
"$([ "${n}" -gt 1 ] 2>/dev/null && echo " (${n}x)")"
fi
echo
done <<< "$_CONNS_RAW"
else
echo " (none)"
echo
fi
}
81 changes: 12 additions & 69 deletions bin/ssh-login-notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ FAIL_LOOKBACK="${FAIL_LOOKBACK:-24 hours ago}"
LOGIN_JOURNAL_TIMEOUT="${LOGIN_JOURNAL_TIMEOUT:-2}"
PUSH_METADATA_LEVEL="${PUSH_METADATA_LEVEL:-full}"

SECLOG_LIB="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/seclog-lib.sh"
[ -f "$SECLOG_LIB" ] || SECLOG_LIB="$HOME/.local/bin/seclog-lib.sh"
# shellcheck source=seclog-lib.sh
. "$SECLOG_LIB"

journalctl_ssh() {
timeout "$LOGIN_JOURNAL_TIMEOUT" journalctl --no-pager "$@" 2>/dev/null
}
Expand Down Expand Up @@ -39,79 +44,17 @@ TTY_NAME="${SSH_TTY:-$(tty 2>/dev/null)}"
TTY_NAME="${TTY_NAME:-none}"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S %Z')

C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_BLU=$'\033[34m'; C_OFF=$'\033[0m'; C_BLD=$'\033[1m'

# Returns country string for an IP, or [LAN] for private ranges.
# Requires: geoip-bin + geoip-database (apt install geoip-bin geoip-database)
geo_lookup() {
local ip="$1" second
# Strip IPv6-mapped IPv4: [::ffff:1.2.3.4] -> 1.2.3.4
ip="${ip#\[}"; ip="${ip%\]}"; ip="${ip#::ffff:}"; ip="${ip#::FFFF:}"
case "$ip" in
127.*|10.*|169.254.*|::1|fe80*) echo "[LAN]"; return ;;
192.168.*) echo "[LAN]"; return ;;
172.*)
second=$(printf '%s' "$ip" | cut -d. -f2)
{ [ "$second" -ge 16 ] && [ "$second" -le 31 ]; } 2>/dev/null && { echo "[LAN]"; return; }
;;
esac
command -v geoiplookup >/dev/null 2>&1 || return
geoiplookup "$ip" 2>/dev/null \
| awk -F': ' '/Country Edition/ { sub(/^[[:space:]]+/,"",$2); print $2; exit }'
}

# ── 1) All active connections (grouped by app + target) ──
_CONNS_RAW=$(ss -tnp 2>/dev/null | awk '
$1 == "ESTAB" {
local = $4; peer = $5
pname = "-"
rest = ""; for (i = 6; i <= NF; i++) rest = rest $i
if (match(rest, /"[^"]+"/)) pname = substr(rest, RSTART+1, RLENGTH-2)
nl = split(local, la, ":"); lport = la[nl]+0
np = split(peer, pa, ":"); pport = pa[np]+0
dir = (lport <= pport) ? "IN" : "OUT"
if (dir == "IN") {
peer_ip = peer; sub(/:[^:]+$/, "", peer_ip)
key = dir "|" peer_ip "|" lport "|" pname
} else {
key = dir "|" peer "|" pname
}
count[key]++
}
END { for (k in count) printf "%s|%d\n", k, count[k] }' \
| sort -t'|' -k1,1r -k2,2)

# ── 1) All active connections ──
ACTIVE_COUNT=$(ss -tnp 2>/dev/null | grep -c ESTAB)
ACTIVE_IPS=$(ss -tnp 2>/dev/null | awk '$1=="ESTAB"{
nl=split($4,la,":"); np=split($5,pa,":")
if (la[nl]+0 <= pa[np]+0) { ip=$5; sub(/:[^:]+$/,"",ip); print ip }
}' | sort -u | paste -sd',' -)

echo "$C_BLD$C_BLU── All active connections ($ACTIVE_COUNT) ──$C_OFF"
if [ -n "$_CONNS_RAW" ]; then
while IFS='|' read -r dir a b c cnt; do
[ -z "$dir" ] && continue
if [ "$dir" = "IN" ]; then
geo=$(geo_lookup "$a")
users=$(who 2>/dev/null | grep -F "($a)" | awk '{print $1}' | sort -u | paste -sd',' -)
echo " [IN ] $a -> :$b"
echo " app: $c | ${geo:-(unknown)}${users:+ | $users}"
else
peer_ip="${a%%:*}"
geo=$(geo_lookup "$peer_ip")
n="${c:-1}"
echo " [OUT] $a"
printf ' app: %s | %s%s\n' "$b" "${geo:-(unknown)}" "$([ "$n" -gt 1 ] 2>/dev/null && echo " (${n}x)")"
fi
echo
done <<< "$_CONNS_RAW"
else
echo " (none)"
echo
fi
show_active_connections

# ── 2) Last 5 successful logins (distinct IPs) ──
echo "$C_BLD$C_GRN── Last 5 successful logins (distinct IPs) ──$C_OFF"
echo "${C_BLD}${C_GRN}── Last 5 successful logins (distinct IPs) ──${C_OFF}"
LAST_LOGINS_RAW=$(journalctl_ssh -r _COMM=sshd-session | awk '
/Accepted/ {
for (i=1; i<=NF; i++) if ($i=="from") {
Expand Down Expand Up @@ -157,16 +100,16 @@ TOTAL_FAILS=0; FAIL_LINES=0
if [ -n "$FAIL_SUMMARY" ]; then
FAIL_LINES=$(echo "$FAIL_SUMMARY" | wc -l)
TOTAL_FAILS=$(echo "$FAIL_SUMMARY" | awk -F'|' '{s+=$1} END{print s}')
echo "$C_BLD$C_RED── ⚠ Failed SSH attempts ($FAIL_LOOKBACK): $TOTAL_FAILS from $FAIL_LINES IP(s) ──$C_OFF"
echo "${C_BLD}${C_RED}── ⚠ Failed SSH attempts ($FAIL_LOOKBACK): $TOTAL_FAILS from $FAIL_LINES IP(s) ──${C_OFF}"
echo "$FAIL_SUMMARY" | awk -F'|' '{printf " %3dx %-16s %-20s user=%s\n", $1, $2, $3, $4}'
elif journal_access_may_be_limited; then
echo "$C_BLD$C_YEL── Failed SSH attempts ($FAIL_LOOKBACK): unavailable (needs journal access) ──$C_OFF"
echo "${C_BLD}${C_YEL}── Failed SSH attempts ($FAIL_LOOKBACK): unavailable (needs journal access) ──${C_OFF}"
else
echo "$C_BLD$C_YEL── Failed SSH attempts ($FAIL_LOOKBACK): none ──$C_OFF"
echo "${C_BLD}${C_YEL}── Failed SSH attempts ($FAIL_LOOKBACK): none ──${C_OFF}"
fi
echo

echo "$C_BLD$C_BLU── Admin Commands ──$C_OFF"
echo "${C_BLD}${C_BLU}── Admin Commands ──${C_OFF}"
echo " sudo firewall-status → UFW + ipset + iptables overview"
echo " sudo ufw status numbered → UFW rules numbered"
echo " sudo ipset list → device groups"
Expand Down
4 changes: 0 additions & 4 deletions config/config.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,3 @@ ALLOW_CUSTOM_REPO_DIR=0
# match one of these values.
EXPECTED_UPDATE_ORIGIN="https://github.com/arn-c0de/seclog-linux.git"
EXPECTED_UPDATE_ORIGIN_ALT="git@github.com:arn-c0de/seclog-linux.git"

# Require `git verify-commit` to succeed for the target commit before applying
# an update. Enable this only after you have configured trusted signing keys.
VERIFY_UPDATE_SIGNATURES=0
Loading