A honeypot that only listens (and fail2ban that bites)
My little Hetzner box serves four public sites, and like every machine with a public IP it has a second, uninvited audience: bots. Within about fifteen minutes of tailing the firewall log I counted dozens of unsolicited SYNs — someone probing for MongoDB on 27017, someone rattling telnet on 23, a sweep for Redis, a knock on 5555. None of it is targeted. It is the background radiation of the internet, and it never stops. I wanted to see that traffic up close, and eventually to do something about it. So I stood up a honeypot — a fake service whose only job is to be knocked on.
The one rule: it listens, it never runs anything
The whole thing is about sixty lines of Python. It binds a port, sends back a believable banner (a real OpenSSH version string), then reads whatever the client types and writes it to a log. The single most important property is what it does not do: it never interprets, evaluates, or executes a single byte the visitor sends. Attacker input is decoded, recorded, and dropped — it is data, never a command.
data = conn.recv(4096)
text = data.decode("utf-8", errors="replace") # decode for the log, never exec
log(f"INPUT {ip} -> {text!r}") # write it down, then drop it
That line is the difference between a honeypot and a footgun. A fake service that shells out, or that “helpfully” tries to act on the commands it receives, is just a remote-code-execution vector wearing a costume. Mine emits exactly three kinds of log line — CONNECT, INPUT, and CLOSE — and that is the entire contract.
A user that can’t do anything
A process that faces the internet should own as little of the machine as possible. The honeypot runs as its own system account with no login shell and no home directory:
useradd --system --no-create-home --shell /usr/sbin/nologin honeypot
--system keeps it out of the normal UID range and out of the interactive-login groups; nologin means that even if a stray key or a misconfigured job ever pointed at this user, the shell itself refuses to start. The script lives in /opt/honeypot, owned by that account, and it binds a high port so it never needs CAP_NET_BIND_SERVICE or a shred of root. There is simply nothing to escalate from.
Durable, and boxed in
A honeypot that dies when my SSH session closes is useless, so it runs under systemd — which also happens to be a tidy place to sandbox it:
[Service]
User=honeypot
ExecStart=/usr/bin/python3 /opt/honeypot/tiny-honeypot.py --port 2222 ...
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/honeypot
RestrictAddressFamilies=AF_INET AF_INET6
Then I reached for one more directive out of reflex: IPAddressDeny=any paired with IPAddressAllow=localhost — the usual “this process may not touch the network” clamp. On a honeypot it is exactly wrong, and wrong silently. systemd’s IP-address filter is not directional: it applies to every socket operation, ingress and egress alike. Allow only localhost and you have not merely stopped the honeypot from dialling out — you have stopped every attacker on the internet from reaching it, which is the one thing it exists to permit. The service starts, the port shows up in ss, and nothing is ever logged.
IPAddressAllow/IPAddressDenyfilter inbound peers too. For a listener that must accept connections from anywhere, they are the wrong tool — drop them and lean on the other sandboxing directives.
I removed the pair. The script never opens an outbound connection in the first place, so there was nothing to contain, and the honeypot went back to hearing the whole internet.
A honeypot only watches — so pair it with something that acts
Logging is satisfying for about a day. A honeypot is a sensor: on its own it changes nothing about who can reach the box. The natural other half is fail2ban, and a honeypot is close to the perfect input for it, because the false-positive rate is essentially zero. Nobody legitimate ever connects to a service that does not really exist, so a single connection is all the evidence you need.

fail2ban already ships watching sshd; adding the honeypot is two small files. First a filter that turns a log line into a banned host:
# /etc/fail2ban/filter.d/honeypot.conf
[Definition]
failregex = CONNECT <HOST>:\d+
datepattern = ^\[%Y-%m-%dT%H:%M:%S
Then a jail that points it at the log and bans hard:
# /etc/fail2ban/jail.d/honeypot.conf
[honeypot]
enabled = true
filter = honeypot
logpath = /opt/honeypot/honeypot.log
maxretry = 1
bantime = 86400
maxretry = 1 because the first knock is already disqualifying; bantime = 86400 drops the source for a day. fail2ban inserts the block into the same firewall the rest of the box already uses, live, and removes it when the ban expires — no restart, no manual cleanup, no state for me to remember.
Run fail2ban anyway — honeypot or not
The honeypot is the fun part, but the boring conclusion is the load-bearing one: if you run anything with a public IP, run fail2ban. Its default sshd jail alone turns the endless credential-stuffing against your real SSH port from a firehose into a trickle, banning the noisiest sources before they manage more than a handful of guesses. The honeypot jail is a strict, satisfying bonus stacked on top — a tripwire that converts pure reconnaissance into an instant, self-expiring ban — but the baseline habit is the point.

A sensor tells you what is happening. fail2ban is what makes the box quieter tomorrow than it is today. Build the lure if you enjoy watching the internet knock — run the jail regardless.