i wrote a honeypot in go that answers ssh, ftp and http without speaking any of them
cybersecurity · Oct 4, 2026 · 6 min read
i wanted to see what knocks on my door at three in the morning. so i wrote beetrap: it listens on three ports, sends a fake service banner, reads one line back, prints it in a tui, and hangs up.
that is the entire product. there is no more to it than that.
what it actually is
three go files, 200 lines, 4,720 bytes of source:
| file | lines | bytes | job |
|---|---|---|---|
cmd/beetrap/main.go |
19 | 367 | make a channel, start the tui |
internal/capture/capture.go |
58 | 1,119 | listen, greet, read one line |
internal/ui/ui.go |
123 | 3,234 | draw the log, count the hits |
go.mod has two direct dependencies and both of them are the tui: bubbletea 0.27.0 and lipgloss 0.12.1. the other 18 are indirect, and every single one of them arrives because of the terminal.
the part i did not expect: the whole network side has zero third-party code. capture.go imports bufio, fmt, io, net, strings, time — all standard library, all of it. the "protocol implementation" is a map from a string to a string:
var _banners = map[string]string{
"SSH": "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n",
"FTP": "220 (vsFTPd 3.0.5)\r\n",
"HTTP": "",
}
that is the entire protocol knowledge in the project, and it is 1,119 bytes long.
why go, specifically
- no root, by default. the ports are 2222, 2121 and 8080, all above 1023, so the first run needs no privileges. a python honeypot would give me the same thing. this is not a reason to like go.
- one goroutine per connection, in one line.
go _handle(conn, service, ch)inside the accept loop. the same shape in java is a thread pool, a bounded queue and a rejection policy. here it is a keyword, and the failure mode is a slowloris sitting on a goroutine for eight seconds. - one static file.
go build -o beetrap ./cmd/beetrapproduces a single executable with no interpreter, no venv, nonode_modules. a honeypot is something you leave running on a machine nobody maintains, so the fewer moving parts the better. - the tui libraries. bubbletea gives you the update-loop architecture for a terminal, lipgloss does the styling. for a read-only log that never scrolls back, the framework costs about 120 lines and saves me from hand-rolling ansi escapes and parsing terminal width.
netwas already in the box. the honest reason: i had never written a listener before, andnet.Listen+Acceptmade it look like four lines. i did not choose go for concurrency. the concurrency came along for free and i never had to earn it.
how i structured it, and why the structure is wrong
there is no router. there are three goroutines, hardcoded, started from the tui's Init():
func (m VxModel) Init() tea.Cmd {
go capture.VxStartListener("SSH", 2222, m.ch)
go capture.VxStartListener("FTP", 2121, m.ch)
go capture.VxStartListener("HTTP", 8080, m.ch)
return _wait(m.ch)
}
that is the routing table: three tuples, in a function that has nothing to do with networking.
the layering is inverted and i would rather name it than quietly refactor it. internal/capture is the package that actually opens sockets, and it has no idea which ports it serves or why. internal/ui — the package whose entire job is drawing coloured text in a terminal — owns the deployment topology. if i add telnet, i edit the drawing code.
the right shape is a config struct built in main, one loop over a slice of services, and a package boundary that knows nothing about ports. i would also make the service name a type instead of a bare string, because right now "SSH" is a lookup key in two maps living in two packages and nothing checks that the two maps agree.
connections reach the screen through one buffered channel of size 32, created in main:
ch := make(chan capture.VxConnection, 32)
p := tea.NewProgram(ui.VxNewModel(ch), tea.WithAltScreen())
the ui never calls the network. it waits on the channel, receives one message, appends it, re-arms the wait. that is the same message loop as the frontend of this site, for the same reason: the thing that draws must not be the thing that blocks. the accept loop only ever writes to a channel and never touches the view.
what the protocols taught me, and i did not expect any of it
the thing i got wrong in my head before writing it: i assumed a honeypot has to speak the protocol. it does not. three services, three completely different rules about who talks first:
| service | port | who speaks first | what we send | what we capture |
|---|---|---|---|---|
| SSH | 2222 | the server | SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6 |
the client's own version string |
| FTP | 2121 | the server | 220 (vsFTPd 3.0.5) |
USER <someone> |
| HTTP | 8080 | the client | nothing at all | GET / HTTP/1.1, or worse |
ssh sends its identification string first. it is the rare protocol where the server speaks unprompted and the client answers with its own. so the line we read back is not a credential, it is a fingerprint: SSH-2.0-OpenSSH_9.6 tells you the client is a real openssh, which version, on which distribution. that is the whole payload of a scanner handshake, and you get it from a single line.
ftp's first line is the username. the server's 220 is the greeting, and the very next thing a client says is USER root, or USER admin, or USER anonymous. one line into the protocol and you have the credential list a bot is walking down, before it has sent anything secret.
http is the inverse, and the empty string in the map is not a bug. an http server that writes anything before reading the request is broken. the map has "HTTP": "" and the handler guards with if b := _banners[service]; b != "" — that guard is the entire reason http behaves, and it is one line that happens to be load-bearing for two protocols and correct for the third.
the general lesson: a honeypot's first read is the highest-value byte it will ever see. identification, not authentication. you learn who is out there from the handshake, and you never have to implement the part where they log in.
what it does not do
- it never sees a password. no key exchange, no
noneauth method, nopam, no/etc/shadow. the socket is greeted, one line is read,defer conn.Close()fires. a realsshclient waits out the 8-second deadline and gives up. that is the correct design, not a missing feature: a honeypot that accepts logins is a liability with a login form on it. - it captures one line, 512 bytes, inside 8 seconds.
io.LimitReader(conn, 512)thenReadString('\n'). a client that sends a binary blob with no newline gets whateverReadStringreturns, which is garbage. fine, as long as i know it. - one goroutine per connection, unbounded. nothing caps concurrency. a socket that connects and sends nothing holds a goroutine for the full eight seconds. a thousand of those is a thousand goroutines. survivable on a personal machine, rude on a small vps.
- nothing is written to disk. the log lives in a slice in ram, trimmed to
height - 10rows only when it is drawn. pressqand it is gone. a honeypot that forgets is a screensaver with a network interface. - the ports cannot be changed without editing go source. there are no flags, no env vars, no config file.
the two bugs i would fix before trusting it
1. a failed bind is completely silent. VxStartListener starts like this:
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return
}
the error is thrown away. not printed, not counted, not rendered. if 2222 is already taken, that goroutine dies during startup and the SSH counter sits at zero forever — next to a live FTP counter, in the same header, looking exactly like a quiet day.
this is the worst failure mode a security tool can have: it is indistinguishable from success. the fix is three lines. send the error down the same channel, paint it red in the header, exit non-zero.
2. the readme tells you to do something the code cannot do. it says:
go build -o beetrap ./cmd/beetrap
sudo setcap cap_net_bind_service=ep ./beetrap
"to bind standard ports (22, 21, 80) without root". you will not bind port 22. the ports are integer literals inside ui.Init(). the capability gets granted to a binary that never asks for a privileged port. the fix is a flag, not a capability:
sshPort := flag.Int("ssh", 2222, "port to fake ssh on")
the intent in that readme section is not wrong, the code is just not connected to it. and the reason is visible in the history: four of the five commits in the repository are titled "Add files via upload". the files were written somewhere else and pasted in, and the readme arrived with them, describing a version of this program that does not exist.
smaller, but real: the accept loop does if err != nil { continue }. when a listener actually goes down, Accept returns that same error immediately and forever, and the goroutine spins at 100% cpu. it should break.
what i would tell my past self
the whole thing is one lesson wearing a network costume: you do not have to implement a protocol to learn from it. you send the right first line, you read the answer, you hang up. the entire intelligence of a honeypot lives in that first read, and everything after it — key exchange, authentication, a fake shell — is cost, liability and code i should be delighted not to have written.
and the second lesson is the boring one, which applies to every tool i have ever shipped: swallow an error and you have shipped something that lies to you by omission. the bind failure is a single return. it is the entire distance between a security tool and a screensaver.