Trapping SSH Attackers with endlessh-go on a VPS
Every public IP gets scanned. Within minutes of a VPS coming online, automated bots are probing port 22 looking for weak SSH credentials, default passwords, and known vulnerabilities. This is not targeted — it is background radiation on the internet.
The standard response is to move SSH to a non-standard port, disable password authentication, and run fail2ban. This works. But it is passive.
endlessh-go takes a different approach. Instead of refusing the connection, it accepts it and then keeps the attacker waiting indefinitely by sending an SSH banner that never completes. The bot sits there waiting. One thread, one connection slot, permanently occupied.
The original endlessh written in C is no longer maintained. endlessh-go is a Go rewrite by shizunge — actively developed, with Prometheus metrics and IPv6 support. That is the version worth running.
How it runs in this setup
I manage all my infrastructure with Ansible through the homelab-orchestra repo. endlessh-go runs as a Docker container — no systemd binary on the host, no manual installation. Everything is in containers, everything is idempotent, everything rolls out with make deploy-endlessh.
The Ansible role deploys it like this:
# roles/endlessh/tasks/main.yml
- name: Deploy endlessh-go container
community.docker.docker_container:
name: "{{ endlessh_container_name }}"
image: "{{ endlessh_image }}:{{ endlessh_version }}"
state: started
restart_policy: unless-stopped
command: >
-port=2222
-conn_type=tcp
-max_clients=4096
-interval_ms=10000
-enable_prometheus=true
-prometheus_port=9229
-geoip_supplier=off
-logtostderr
-v=1
published_ports:
- "{{ endlessh_port }}:2222"
The container listens internally on port 2222 and is mapped externally to port 22. Real SSH runs on a different port — pulled from Ansible Vault via ansible_port — and is not documented anywhere publicly.
The role defaults:
# roles/endlessh/defaults/main.yml
endlessh_image: "shizunge/endlessh-go"
endlessh_version: "2026.0328.0"
endlessh_port: 22
endlessh_container_name: endlessh
The version is pinned. Never latest as an image tag — that is a hard rule in the repo.
Stats by email
What I care about is not just whether it is running but what it is seeing. A stats script runs twice daily via cron — at 12:00 and 00:00 — and sends a report by email.
# roles/endlessh/templates/endlessh-stats.sh.j2 (simplified)
CONTAINER_LOGS=$(docker logs endlessh --since 12h 2>&1)
TOTAL_CONNECTIONS=$(echo "$CONTAINER_LOGS" | grep -c "new client")
UNIQUE_IPS=$(echo "$CONTAINER_LOGS" | grep -oP 'client=\K[0-9.]+' | sort -u | wc -l)
TOP_IPS=$(echo "$CONTAINER_LOGS" | grep -oP 'client=\K[0-9.]+' | sort | uniq -c | sort -rn | head -10)
TRAPPED_SECONDS=$(echo "$CONTAINER_LOGS" | grep "close client" \
| grep -oP 'time=\K[0-9.]+' | awk '{s+=$1} END {printf "%.0f", s}')
The email looks like this:
Subject: [ENDLESSH] strato-rs1 — honeypot stats (last 12h)
Connections: 847
Unique IPs: 312
Time wasted: 94320 seconds
Bytes sent: 18432
Top 10 attackers:
43 185.234.xxx.xxx
31 45.142.xxx.xxx
28 194.165.xxx.xxx
...
The cron jobs are deployed by Ansible as well:
- name: Configure endlessh stats email at 12:00
ansible.builtin.cron:
name: "endlessh stats noon"
minute: "0"
hour: "12"
job: "{{ endlessh_stats_script }}"
user: root
- name: Configure endlessh stats email at 00:00
ansible.builtin.cron:
name: "endlessh stats midnight"
minute: "0"
hour: "0"
job: "{{ endlessh_stats_script }}"
user: root
Email delivery goes through msmtp, deployed by the base role with credentials from Vault.
What Strato VPS traffic looks like
Strato IP ranges in Germany are well indexed. The moment a new IP appears in their allocation it gets picked up by Shodan, Censys, and the bot networks that read those feeds. Within the first hour of deployment you already have active connections in the tarpit. Within the first day, typically several hundred connection attempts from dozens of unique IPs.
The same IP ranges show up on both Strato VPS instances — strato-rs1 and strato-rs2 — because scanners work through entire ranges systematically, not individual hosts.
Deploying it
make deploy-endlessh
That is it. Ansible ensures Docker is running, pulls the image, starts the container, deploys the stats script, and sets the cron jobs. Second run produces no changes because everything is idempotent.
What it does and does not do
endlessh-go wastes resources for low-sophistication, high-volume scanning bots. Bots that open a connection and wait — which is most of them — sit in the tarpit. Any scanner with a short connection timeout disconnects immediately and moves on.
What it does not do is stop targeted attackers. Anyone who knows what they are doing sees the slow banner immediately and moves to the next target.
The real value: port 22 is the tarpit, SSH is on a port that automated scanners are not probing. The attack surface for the real SSH daemon is effectively zero for automated scanning traffic. The stats emails give a concrete picture twice a day of what is hitting the public IPs.