Central React dashboard + Rust API + lightweight log-shipper agents. Modelled on Beszel (hub/agent) and Dozzle (distributed logs): agents push over your tailnet, central aggregates. SQLite holds derived state. Logs stay source of truth.
agent (any server) --tailscale+bearer--> central (main server) --> UI
curl -fsSL https://raw.githubusercontent.com/authoritydmc/ssh-sentinel/master/scripts/ai-install.sh | bashThen paste the prompt from AI.md into your AI. The AI clones,
configures, deploys, and verifies. It obeys AGENTS.md.
| Area | What you get |
|---|---|
| Overview | Failed attempts, attacker IPs, successful logins, peak hour, top origin, most-wanted user |
| Trends | 48h failed-vs-accepted timeline, auth-outcome donut |
| Map | World attack map (lat/lon pins) + top attacking regions |
| Attackers | Top 25 user@IP pairs, flag, city/org/ASN, recon badge, click for full intel |
| Intel modal | Geo, org/ISP, ASN, rDNS, RDAP net, first/last seen, users tried, 48h activity bars, full log chain, optional SpiderFoot recon (cached 7d) |
| Live events | Tailable /var/log/auth.log (or fleet-merged), filter, pause, newest/oldest first, 15s refresh |
| Fleet | N agents push over HTTPS+bearer; host filter (All + per-host), online dot (120s), per-host timelines |
| Self-noise filter | Container/node egress + SELF_PUBLIC_IPS excluded from attacker stats (count shown separately) |
| Ops | Single static image, healthcheck on /healthz, no DB, JSON files only |
All shots use the header mask toggle. It hides IPs, usernames, host names, and session lines on every page. Only attacker data shows: attacker IPs, geo, and tried users. The choice persists per browser.
- Single VPS watchtower β see who is hammering your SSH right now, which users they want, which countries they come from.
- Fleet SOC β 5β50 servers behind Tailscale, one dashboard. Agents push outbound only; members open zero inbound ports.
- Post-incident review β click an IP β full log chain + RDAP/rDNS + recon findings, export the story.
- Demo / UI work β synthetic 48h, 10-country log (
--profile demo) without touching real auth logs. - Homelab gateway β runs next to Traefik/Authentik; keep 8079 on tailnet, put SSO in front for remote access.
Full design (diagrams + procedures, simplified English): docs/ARCHITECTURE.md.
ββββββββββββββ tail + push (10s) βββββββββββββββββββββββ
β member-01 β ββ Bearer:TOKEN ββββΆ β central β
β /var/log/ β outbound only β ssh-sentinel (Rust) β
β auth.log β β + React dist β
ββββββββββββββ β data/hosts/*.jsonl β
ββββββββββββββ β data/agents.json β
β member-02 β ββββββββββββββββββββΆ β :8079 /healthz β
ββββββββββββββ βββββββββββ¬ββββββββββββ
β :8079 (tailnet only)
βΌ
React 19 SOC dashboard
- Backend (
central-rs/): Rustssh-sentinelbinary, servesdist/+ JSON API, parses syslog + ISO timestamps, enriches via ip-api/RDAP/rDNS (cached), optional SpiderFoot recon. - Agent (
agent-rs/): static Rust tailer, inode+offset resume across rotation/restarts, exponential backoff,PUSH_EVERY=10default. - Frontend (
frontend/): Vite + React 19 + Tailwind v4, code-split charts/map/modal, 60s auto-refresh, base path/ssh/.
# Docker Hub
docker run -d --name ssh-sentinel --restart unless-stopped \
-p 8079:8079 \
-e HOST_ID=central \
-v sentinel_data:/srv/data \
-v /var/log/auth.log:/var/log/auth.log:ro \
rajlabs/ssh-sentinel:latest
# or GHCR
docker run -d --name ssh-sentinel --restart unless-stopped \
-p 8079:8079 \
-e HOST_ID=central \
-v sentinel_data:/srv/data \
-v /var/log/auth.log:/var/log/auth.log:ro \
ghcr.io/authoritydmc/ssh-sentinel:latest
# open http://localhost:8079 (or http://<tailnet-name>:8079 in fleet)
curl -s localhost:8079/healthz # -> okgit clone https://github.com/authoritydmc/ssh-sentinel.git && cd ssh-sentinel
cp .env.example .env # set HOST_ID + SELF_PUBLIC_IPS + AUTH_* login
docker compose up -d --build
# open http://localhost:8079 (image default AUTH_MODE=local is fail-closed:
# set AUTH_USER + AUTH_PASS_HASH in .env, or AUTH_MODE=none on a private net)Mint a local-login hash (never commit the password or hash):
docker exec -it ssh-sentinel /srv/ssh-sentinel genhash
# -> AUTH_PASS_HASH=pbkdf2-sha256$200000$... (paste into .env, compose up -d)# From source (needs a Rust toolchain):
cargo install --git https://github.com/authoritydmc/ssh-sentinel ssh-sentinel
cargo install --git https://github.com/authoritydmc/ssh-sentinel ssh-sentinel-agent
# From crates.io once published: cargo install ssh-sentinel
# Or fetch a release binary (Linux, Windows, macOS assets on every tag):
# https://github.com/authoritydmc/ssh-sentinel/releases
AUTH_LOG=/var/log/auth.log DATA_DIR=./data ./ssh-sentinel
Behind Authentik + Traefik instead (same ForwardAuth pattern as Dozzle):
```yaml
# central labels (illustrative) β Authentik decides who gets in,
# central trusts the passed identity in AUTH_MODE=forward/oidc
labels:
- "traefik.http.routers.ssh.middlewares=authentik@docker"
- "traefik.http.middlewares.authentik.forwardauth.address=http://authentik:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-Forwarded-User,X-Forwarded-Email"
# central env for that setup
AUTH_MODE=forward
AUTH_ALLOWED_USERS=alice,bob@example.com # optional allowlist
# headers are only honored from AUTH_TRUSTED_PROXIES (spoof-safe by default)Central speaks OIDC itself (authorization-code flow, RS256, server sessions). In Authentik, create a Provider (type OAuth2/OpenID, confidential client) plus an Application, and set:
- Redirect URIs:
https://<your-host>/oidc/callback - Signing key: any RSA key (RS256 is required)
- Scopes:
openid email profile
# central env
AUTH_MODE=oidc
OIDC_ISSUER=https://auth.example.com/application/o/ssh-sentinel/
OIDC_CLIENT_ID=<from authentik>
OIDC_CLIENT_SECRET=<from authentik>
OIDC_REDIRECT_URL=https://<your-host>/oidc/callback
AUTH_ALLOWED_USERS=alice@example.com # optional allowlistOpen the UI β it redirects to Authentik β back with a session cookie
(HttpOnly, 12h default via OIDC_SESSION_TTL). /oidc/logout ends it.
OIDC_COOKIE_SECURE=1 when central serves HTTPS directly.
Or copy-paste ready: examples/sso-traefik-authentik.yml
SSO_HOST=ssh.example.com AUTH_ALLOWED_USERS=alice@example.com \
docker compose -f docker-compose.yml -f examples/sso-traefik-authentik.yml up -d --buildOther SSO front doors that work with AUTH_MODE=forward (no code changes):
| Provider | How |
|---|---|
| Authelia + Traefik/Nginx | ForwardAuth authResponseHeaders: Remote-User, Remote-Email β trusted Remote-User header is already accepted |
Cloudflare Access + cloudflared |
Access policy on the hostname; central accepts Cf-Access-Authenticated-User-Email (cloudflared talks to loopback, inside default trusted proxies) |
| Tailscale Serve | keep AUTH_MODE=none on tailnet-only :8079 (identity = tailnet), or put Authentik in front as above |
Click any IP β Recon auto-enriches via RECON_PROVIDER:
spiderfoot(default): needs a reachableSPIDERFOOT_URL; tune withRECON_MODULES. Unreachable backend β cleanerrorstate, never blocks the UI. Start the bundled backend withdocker compose --profile recon up -d --build. Central then useshttp://spiderfoot:5001automatically. The SpiderFoot UI binds loopback only (SPIDERFOOT_PORT). SpiderFoot has no login, so keep it private.webhook: plug any probing service β POST{"ip": "1.2.3.4"}toRECON_WEBHOOK_URL(optionalRECON_WEBHOOK_TOKENbearer), return{"findings": [{"type": "ASN", "data": "ASβ¦", "module": "my-source"}]}. AcceptseventType/finding/value/infoandsource/provideraliases. Results cached 7d like SpiderFoot scans.none: recon section reports disabled (no external calls at all).
docker compose --profile demo up --build
# open http://localhost:8081 (sample fleet log, 10 countries, 48h)The sample log is committed and frozen (seeded, deterministic).
On central: run the central service as above, then mint a join token:
docker exec ssh-sentinel /srv/ssh-sentinel gentoken web-01
# -> host=web-01, token=..., central=http://<this-host>:8079On the joining server (needs tailscale status so it can reach central by tailnet name) β pick one role runner:
# Option 1 β container agent (same image, ROLE=agent, no systemd needed)
ROLE=agent AGENT_ID=web-01 CENTRAL_URL=https://<central-tailnet-name>:8079 \
AGENT_TOKEN=<token> docker compose --profile agent up -d --build
# Option 2 β host systemd agent (Linux, fetches the static binary)
git clone https://github.com/authoritydmc/ssh-sentinel.git && cd ssh-sentinel
CENTRAL_URL=http://<central-tailnet-name>:8079 AGENT_TOKEN=<token> sudo -E ./agent/install.sh
# or without clone: set AGENT_ID explicitly
CENTRAL_URL=http://central:8079 AGENT_TOKEN=<token> AGENT_ID=web-01 sudo -E ./agent/install.sh
# Option 3 β native binary (macOS, Windows, or any Linux)
# Download the matching asset from GitHub Releases and run it directly.
# Linux asset names use x86_64/aarch64, macOS uses the same, Windows adds .exe.
Verify on central:
```bash
curl -s localhost:8079/api/hosts | jq
curl -s 'localhost:8079/api/summary?host=web-01' | head -c 500The UI gains a host filter (All hosts + per-host pills with online dot) once agents report.
Rotate a host: gentoken <host> again on central, update /etc/ssh-sentinel-agent/env on the member, systemctl restart ssh-sentinel-agent.
| View | Route state | What to do |
|---|---|---|
| Overview | sidebar β Overview | 6 metric cards, SSH activity trends (failed red / accepted green), auth donut, world map, top regions, top-8 attackers |
| Attackers | sidebar β Attackers | full top-25 table, filter by IP/user/country, click row β intel modal |
| Intel modal | click any IP | location, org/ASN, rDNS, RDAP, hits + first/last + users tried, 48h bars, Show full log chain, Recon (auto-start, cached 7d, re-run button) |
| Live events | sidebar β Live events | filter (e.g. Accepted or an IP), pause/resume, order toggle, 15s refresh, severity badges (attack/auth/system) |
| Host pills | header | all hosts vs per-host, hover shows last-seen, green = online (<120s) |
The UI is the React build in dist/ (ships in Docker). Without dist/, unknown routes return 404.
Same origin, no auth for reads (keep behind tailnet/SSO). Agent push requires bearer.
| Method | Path | Params | Notes |
|---|---|---|---|
GET |
/healthz |
β | ok (Docker healthcheck, always open) |
GET |
/api/health |
β | open liveness snapshot: status, uptime, version, mode, hosts, log/data checks (for Uptime Kuma etc.) |
GET |
/oidc/login, /oidc/callback, /oidc/logout |
β | built-in SSO flow in oidc mode (302 redirects + session cookie) |
GET |
/api/auth |
β | {mode, login, user, safe} β lock badge source, always open |
GET |
/api/version |
β | build info: version, short commit, full changelog text. Powers the footer About dialog. Always open, no secrets |
GET |
/api/summary |
?host=all|<id> |
total, ips, top[25], timeline[48h], logins[60], excluded_self, hosts (login required unless AUTH_MODE=none) |
GET |
/api/hosts |
β | [{id, local, last_seen, online, lines}] (login required unless none) |
GET |
/api/tail |
?q=&n=200&host= |
plain-text log slice, n clamped 10β2000, case-insensitive substring (login required unless none) |
GET |
/api/ipinfo |
?ip=&host= |
geo + rdap + rDNS + history{users,hits,first,last,timeline} (login required unless none) |
GET |
/api/abusers |
?host=&page=&per_page= |
public-safe attacker feed: ip, hits, first/last, attempted users, geo/org/ASN, risk + band + reasons. Repeat offenders only (β₯ABUSERS_MIN_HITS fails, score β₯ABUSERS_MIN_SCORE, no successful login, never whitelisted/private). Paginated (β€200/page), cached 60s, rate-limited (ABUSERS_RPM, 429 + Retry-After). Never exposes accepted logins, hostnames, internal/self IPs, or raw lines. Behind the login gate unless ABUSERS_PUBLIC=1 (or AUTH_MODE=none) |
GET |
/abusers |
β | public leaderboard page (same safe data, no login) β works only with ABUSERS_PUBLIC=1, else 404 |
GET |
/api/self |
β | open self-check: your IP, list status, risk, whitelist state. The UI shows a red banner when your IP is listed ("ask the admin to whitelist you") |
GET |
/api/admin/status |
β | open setup status: setup_needed, ban/report flags. No secrets |
GET |
/api/admin/config |
β | enforcement values plus env lock plus source (login required) |
POST |
/api/admin/config |
{ban_*, report_*} |
save enforcement overrides to DB. Env set locks a field. No restart (login required) |
GET |
/api/admin/bans |
β | active bans: ip, jail, reason, source, created, expiry, firewall state (login required) |
GET |
/api/banlist |
β | plain-text banned IPs, one per line (login required; same host can read banlist.txt directly) |
GET |
/api/admin/activity |
?limit= |
latest admin actions: bans, unbans, reports, setups (login required) |
GET |
/api/admin/reports |
β | abuse-report history per IP and provider (login required) |
POST |
/api/admin/ban |
{"ip","reason"} |
ban an IP (fail2ban + DB + banlist). 400 for bad/private/whitelisted IPs |
POST |
/api/admin/unban |
{"ip"} |
remove a ban |
POST |
/api/admin/setup |
{"token","user","password"} |
first-setup only: creates the admin login. 410 after setup closes |
POST |
/api/admin/password |
{"old","new"} |
change the file-based admin password (local mode only) |
POST |
/api/admin/report |
{"ip","hits","risk","band"} |
report one IP now (throttled per provider) |
POST |
/api/recon |
?ip=&force= |
SpiderFoot scan orchestration; cached|started|running|done|error (poll). 400 for private IPs / unreachable SpiderFoot (login required unless none) |
POST |
/api/agent/push |
Authorization: Bearer <token> + {"host","lines":[]} |
max 5000 lines/req, 2000 chars/line, capped at 60k lines/host |
Examples:
curl -s 'http://localhost:8079/api/summary?host=all' | jq | head -n 40
curl -s 'http://localhost:8079/api/tail?q=Accepted&n=5'
curl -s 'http://localhost:8079/api/ipinfo?ip=77.91.71.90' | jq | head -n 40
curl -X POST 'http://localhost:8079/api/recon?ip=77.91.71.90'| Var | Default | Where | Purpose |
|---|---|---|---|
HOST_ID |
hostname | central | Local host id in fleet view |
PORT / DEMO_PORT |
8079 / 8081 |
compose | Host port mapping |
SELF_PUBLIC_IPS |
`` | central | Comma-separated own public IPs to exclude from attacker stats (NAT hairpin) |
AUTH_LOG |
/var/log/auth.log |
central | Live log path inside container (/srv/demo/auth.log.sample in demo) |
DATA_DIR |
/srv/data |
central | Hosts + agents.json volume |
CENTRAL_URL |
http://central:8079 |
agent | Central base URL (use tailnet name in fleet) |
AGENT_TOKEN |
`` (required) | agent | Bearer from gentoken <host> |
AGENT_ID |
hostname | agent | Must match the gentoken host id |
PUSH_EVERY |
10 |
agent | Push interval seconds |
SPIDERFOOT_URL |
http://spiderfoot:5001 |
central | Optional recon backend; recon endpoints error gracefully if unreachable |
RECON_MODULES |
sfp_dnsresolve,sfp_whois,sfp_ipapico,sfp_abusech |
central | SpiderFoot module list |
AUTH_MODE |
local |
central | local (Basic login, fail-closed) | forward (Authentik+Traefik ForwardAuth via SSO headers) | oidc (built-in SSO code flow) | none (open β private tailnet/demo only) |
AUTH_USER |
admin |
central | Local-login username |
AUTH_PASS_HASH |
`` | central | pbkdf2-sha256$β¦ from docker exec ssh-sentinel /srv/ssh-sentinel genhash (preferred over AUTH_PASSWORD) |
AUTH_PASSWORD |
`` | central | Plaintext fallback (never logged); prefer the hash |
AUTH_ALLOWED_USERS |
`` | central | Optional allowlist for forward mode, e.g. alice,bob@example.com |
AUTH_TRUSTED_PROXIES |
loopback + RFC1918 + Tailscale CGNAT | central | CIDRs allowed to present SSO identity headers (spoof-safe ForwardAuth) |
RECON_PROVIDER |
spiderfoot |
central | spiderfoot (needs SPIDERFOOT_URL) | webhook (POST {ip} to RECON_WEBHOOK_URL, returns {findings:[{type,data,module}]}) | none (recon disabled) |
RECON_WEBHOOK_URL / RECON_WEBHOOK_TOKEN |
`` | central | Your intel hook (n8n, custom APIβ¦) + optional Bearer |
ABUSERS_RPM |
60 |
central | /api/abusers per-client-IP requests/minute (429 past budget) |
ALERT_WEBHOOK_URL / ALERT_WEBHOOK_TOKEN |
`` | central | Login + spike alerts (login.suspicious, spike.bruteforce); falls back to abuse webhook; test via POST /api/alerts/test |
ALERT_ON_SUCCESS / ALERT_SPIKE_THRESHOLD / ALERT_SPIKE_WINDOW_S / ALERT_DEDUPE_S |
1 / 20 / 300 / 3600 |
central | Alert tuning: suspicious logins on, spike bar, window seconds, resend delay |
WHITELIST_IPS / TRUSTED_IPS / TRUSTED_USERS |
`` | central | UI editable in Admin Settings. Whitelist never lists or bans. Trusted tunes suspicious logins. |
BAN_ENABLED / BAN_AUTO / BAN_* |
off | central | UI editable in Admin Settings. Env set locks the field. See docs/FAIL2BAN.md. |
REPORT_* / ABUSERS_MIN_* / ABUSERS_PUBLIC |
off | central | UI editable in Admin Settings. Env set locks the field. |
- n8n β Import
examples/n8n-ssh-alerts.json, activate, copy the test URL. - Set
ALERT_WEBHOOK_URLto that URL (Coolify env or.env). - Replace the
Notify (edit me)node with Slack/Telegram/email. curl -X POST <central>/api/alerts/testβ execution appears in n8n. |TLS_CERT/TLS_KEY| `` | central | Container paths to PEM cert/key β enables in-repo TLS 1.3-only listener (else terminate at Tailscale/Traefik) |
Files:
| Path | What |
|---|---|
frontend/ |
Vite + React 19 + Tailwind v4 SOC dashboard (code-split) |
central-rs/ |
Rust API + static dist serving + agent push + host store |
agent-rs/ |
static Rust shipper (systemd via install.sh, or agent-rs/Dockerfile) |
agent-rs/ |
Rust shipper pilot, same protocol, static image (see agent-rs/README.md) |
demo/ |
sample log + generator for UI work without real attacks |
Dockerfile |
node build + cargo build β debian-slim runtime (no Python) |
.github/workflows/ |
docker.yml (GHCR + Docker Hub publish), ci.yml (frontend lint/build + cargo test) |
Read SECURITY.md before exposing anything.
- Login is fail-closed by default (
AUTH_MODE=local): UI + read APIs need HTTP Basic (AUTH_USER+AUTH_PASS_HASH/AUTH_PASSWORD);/healthzand/api/authstay open. No credential configured β deny-all with a setup hint. AUTH_MODE=forward/oidctrusts SSO identity headers (X-Forwarded-User/Email, AuthentikX-authentik-username/X-authentik-email, AutheliaRemote-User, Cloudflare Access email) from Authentik-via-Traefik ForwardAuth (Dozzle-oidc pattern) only when the connection comes fromAUTH_TRUSTED_PROXIES(spoof-safe); optionalAUTH_ALLOWED_USERSallowlist; else401/403.AUTH_MODE=noneis the explicit open flag for private tailnet/demo only.- Agents push outbound only (no inbound ports on members).
- Bearer per-host tokens in
data/agents.json(0600); Tailscale gives WireGuard identity + encryption. Optional in-repo TLS 1.3-only listener viaTLS_CERT/TLS_KEY; agentCENTRAL_URL=https://β¦already verifies with system roots. - Never expose 8079 publicly without SSO in front (Tailscale Serve / Cloudflare Access / Authelia).
/api/abusers(+/abuserspage) is safe-fields-only by construction (no accepted logins, hostnames, internal/self IPs, or raw lines) and lists repeat offenders only: β₯5 fails, risk-scored (attempts, user breadth, recency, optional AbuseIPDB confidence), auto-excluded on any successful login. Stays behind the login gate unlessABUSERS_PUBLIC=1β still rate-limited (ABUSERS_RPM,429+Retry-After).WHITELIST_IPSremoves owner/admin IPs from every attacker list (abusers, top table, map)./api/selftells each visitor their own list status..envanddata/are git-ignored; only.env.exampleships. Tokens are runtime-minted viasecrets.token_urlsafe(32).- Demo log is 100% synthetic (seeded sample) β safe to share screenshots.
Agent-land rules (docs style, checks, hooks): AGENTS.md.
scripts/install-hooks.sh # once per clone: commit-msg + pre-commit + pre-push gates
# frontend
cd frontend && npm ci && npm run dev # vite dev
npm run build # tsc + vite -> dist/ (served by ssh-sentinel)
npm run lint # oxlint
# backend (no deps)
cargo run --manifest-path central-rs/Cargo.toml # serves :8079
AUTH_LOG=demo/auth.log.sample cargo run --manifest-path central-rs/Cargo.toml
# agent
CENTRAL_URL=http://localhost:8079 AGENT_TOKEN=dummy cargo run --manifest-path agent-rs/Cargo.toml
# full stack
docker compose up --build
docker compose --profile demo up --buildWhen the API answers 401, the UI shows a sign-in panel. It explains the active mode. In oidc mode it shows a Sign in with SSO button.
Footprint is tracked in CI (image size plus RSS plus CPU per run). Rust plans live in docs/RUST_MIGRATION.md.
Images publish on every master push + tags via GitHub Actions:
ghcr.io/authoritydmc/ssh-sentinel:latest(+:prod-<commit>, semver on tags)rajlabs/ssh-sentinel:latest(same tags, requiresDOCKER_HUB_USERNAME+DOCKER_HUB_ACCESS_TOKENsecrets)
Tags are always semver (vX.Y.Z via scripts/release.sh). No date tags.
Native binaries (ssh-sentinel, ssh-sentinel-agent for Linux,
Windows, macOS) attach to every GitHub Release automatically.
See CHANGELOG.md. License: MIT.







