01

Overview

teachatty is a terminal chat for developers and sysadmins. A server keeps rooms in memory and relays messages; each person runs the client in their own terminal, joins a room and talks. It's built to run on your own company's server.

teachatty is free software under the GNU AGPL-3.0, and will stay that way. Contributions are welcome: see CONTRIBUTING.md.

Status: early development. It works for chatting between machines, encrypted with TLS by default, but breaking changes are on the way. See the limitations.

server
cmd/teachatty-server. Listens on port 7347, keeps rooms in memory, relays each message to the other members of the room.
client
cmd/teachatty. In a terminal, it opens the full screen: messages on top, input box at the bottom. In a pipe (command | teachatty), it sends each line and exits.
room
Any name. Joining a room that doesn't exist creates it; when the last member leaves, it stops existing. The exception is local, the default room: it starts with the server and exists as long as it runs. Anyone who doesn't pick a room lands there.
alias
The name in front of your messages: daniel: hi. Defaults to your system user. Not verified.
message
One line of text. It only goes to whoever is in the room at that moment: there's no history.
02

Getting started

Requirements: a UTF-8 terminal, and port 7347 open on the server machine. Go 1.26 or newer only if you install or build from source.

Install

Packages for Debian/Ubuntu (.deb) and Fedora/RHEL (.rpm), for amd64 and arm64, plus Linux and macOS tarballs and checksums.txt, are at teachatty.com/downloads. The latest names always point to the newest release, currently v0.1.0 (changelog). The packages include man pages: man teachatty and man teachatty-server.

The client (teachatty), what everyone needs:

# Debian, Ubuntu, Mint
curl -LO https://teachatty.com/downloads/teachatty_latest_amd64.deb
sudo apt install ./teachatty_latest_amd64.deb

# Fedora, RHEL, Rocky, Alma
sudo dnf install https://teachatty.com/downloads/teachatty-latest.x86_64.rpm

# anywhere with Go 1.26+
go install github.com/daniel-cmdline/teachatty/cmd/teachatty@latest

The server (teachatty-server), only to host your own rooms. The packages also install the systemd service (see as a service):

# Debian, Ubuntu, Mint
curl -LO https://teachatty.com/downloads/teachatty-server_latest_amd64.deb
sudo apt install ./teachatty-server_latest_amd64.deb

# Fedora, RHEL, Rocky, Alma
sudo dnf install https://teachatty.com/downloads/teachatty-server-latest.x86_64.rpm

# anywhere with Go 1.26+
go install github.com/daniel-cmdline/teachatty/cmd/teachatty-server@latest

Nothing else to set up. The packages install into /usr/bin, which is already in your PATH, so teachatty --server teachatty.com works from any terminal, in any folder. go install puts the binaries in ~/go/bin, which Go's standard setup adds to your PATH; if the command isn't found, add export PATH=$PATH:$HOME/go/bin to your ~/.bashrc.

Try the public server

A public teachatty server runs at teachatty.com. Without a port, the client uses 7347. With no --room, you land in local, the lobby everyone shares. To meet friends, make up a room name and send them the same command: the room appears when the first person joins. Rooms have no passwords yet, so pick a name that's hard to guess. Pipe mode is turned off on the public server (--no-pipes).

teachatty --server teachatty.com
teachatty --server teachatty.com --room tea-with-friends

Run from source

git clone https://github.com/daniel-cmdline/teachatty.git
cd teachatty

# server
go run ./cmd/teachatty-server
# teachatty-server listening on :7347 · default room: local · TLS · fingerprint SHA256:...

# client, in another terminal: no flags, joins the local room
go run ./cmd/teachatty
# * trusting new server localhost:7347 (fingerprint SHA256:...), saved in ~/.config/teachatty/known_servers

# help, on both
go run ./cmd/teachatty -h
go run ./cmd/teachatty-server -h

Another machine on the network

Point the client at the server's address and make sure port 7347 is open in its firewall:

go run ./cmd/teachatty --server 192.168.0.10:7347

To use another port, or listen on a single interface, the server has --addr. The client points at the same address with --server:

go run ./cmd/teachatty-server --addr :9000
go run ./cmd/teachatty --server 192.168.0.10:9000

Install or build the binaries

The folders are named after the binaries, so go install and go build produce teachatty and teachatty-server:

go install github.com/daniel-cmdline/teachatty/cmd/teachatty@latest
go install github.com/daniel-cmdline/teachatty/cmd/teachatty-server@latest

# or, from a clone
go build ./cmd/teachatty
go build ./cmd/teachatty-server

./teachatty-server
./teachatty --room dev --alias daniel

Run the server as a service

The easiest way is the teachatty-server package: it installs the binary, a hardened systemd unit, the teachatty system user and the certbot hook. Flags go in /etc/default/teachatty-server, which upgrades never overwrite:

curl -LO https://teachatty.com/downloads/teachatty-server_latest_amd64.deb
sudo apt install ./teachatty-server_latest_amd64.deb
# or: sudo dnf install https://teachatty.com/downloads/teachatty-server-latest.x86_64.rpm

# with a domain: Let's Encrypt, then point TEACHATTY_SERVER_ARGS at the copied files
sudo certbot certonly --standalone -d chat.example.com \
  --deploy-hook /usr/share/teachatty-server/teachatty-deploy-hook.sh
sudoedit /etc/default/teachatty-server

sudo systemctl enable --now teachatty-server
journalctl -u teachatty-server -f      # logs, including the fingerprint

Without the package, the repo ships the same unit and hook in contrib/. The server then starts on boot, restarts if it crashes, logs to the journal and runs as its own unprivileged user.

# 1. a system user for the server, and the binary
sudo useradd --system --home /var/lib/teachatty --shell /usr/sbin/nologin teachatty
go install github.com/daniel-cmdline/teachatty/cmd/teachatty-server@latest
sudo install -m 0755 ~/go/bin/teachatty-server /usr/local/bin/

# 2. the service (from contrib/systemd/)
sudo cp contrib/systemd/teachatty-server.service /etc/systemd/system/

# 3a. with a domain: Let's Encrypt + the deploy hook (from contrib/certbot/)
sudo install -D -m 0755 contrib/certbot/teachatty-deploy-hook.sh /usr/local/lib/teachatty/teachatty-deploy-hook.sh
sudo certbot certonly --standalone -d chat.example.com \
  --deploy-hook /usr/local/lib/teachatty/teachatty-deploy-hook.sh

# 3b. without a domain: edit the service and switch to the plain ExecStart line
#     (self-signed certificate, created on first start)

# 4. start it, now and on every boot
sudo systemctl daemon-reload
sudo systemctl enable --now teachatty-server
journalctl -u teachatty-server -f      # logs, including the fingerprint
the hook
Let's Encrypt's privkey.pem is readable only by root. After every renewal, certbot runs the hook as root, and it copies the certificate and key into /var/lib/teachatty/tls/, owned by the teachatty user: the key first, then the certificate, so the server never loads a mismatched pair. The server notices the new files and reloads them by itself, so renewals don't disconnect anyone.
the unit
Restarts on failure, keeps its files in /var/lib/teachatty (StateDirectory, with XDG_CONFIG_HOME=/var/lib), and is locked down: no capabilities at all (port 7347 is above 1024), a read-only system (ProtectSystem=strict), no access to home directories, a private /tmp, no new privileges, only network sockets.
ports
7347/TCP for teachatty. For certbot --standalone, port 80 must be reachable while the certificate is issued and renewed.
03

Client reference

Flags

flagdefaultdescription
--serverlocalhost:7347server address, host:port. Without a port, 7347: --server teachatty.com is teachatty.com:7347, and both count as the same server for trust on first use
--roomlocalroom to join. Created if it doesn't exist. local always exists
--aliasyour user ($USER)your name in the chat, in front of each message. Without $USER (cron, some containers): anonymous
--no-tlsoffconnect over plain ws://, for a server started with --no-tls
--versionprint the version and exit

Keys

keyaction
Entersends the message. Empty or whitespace-only messages are ignored
PgUp / PgDnscroll the message area. With PgUp, it stops following the newest
any typingjumps the message area back to the end and follows the newest again
Ctrl+Cquits
arrows, Home, End, Backspaceedit the text in the box

Behavior

your message
Shows on your screen as alias: text, written by the client itself. The server doesn't echo a message back to its sender.
input box
Shows type a message… while empty. Grows from 1 to 5 lines with the text and breaks lines at any character. Above 5 lines, it scrolls inside.
mentions
When someone writes @yourname (whole word, any case), the message shows in amber and bold, and the terminal bell rings: a terminal in another tab or window gets your attention. email@yourname.com doesn't count.
joining
A welcome banner with the teacup: the server and room, how the connection is secured (TLS version and the start of the fingerprint), and the keys. The first-use trust notice, if any, comes right after it.
message area
The title is room · alias, in amber; borders are mint, on your terminal's own background. Each message starts with the time it arrived (15:38, gray). Each alias has a fixed color, the same on every client; yours is bold on the messages you sent. Long lines wrap between words. No mouse: selecting and copying text works like in any terminal.
no terminal
If stdin isn't a terminal, the client goes into pipe mode instead of opening the screen.

Output and exit codes

situationmessagecode
Ctrl+C—0
the connection drops (server stopped, network down)connection closed: + the reason, e.g. close 1006 (abnormal closure)1
couldn't connectthe connection error, e.g. connection refused1
too many connections from your IP (server's --max-per-ip)the server refused the connection: too many connections from your address1
pipe mode into a server started with --no-pipesthe server refused the connection: this server doesn't accept pipe mode ...1
the server's fingerprint changedthe identity of server ... has changed, with both fingerprints (security)1
TLS on one side onlythe error + a hint: try --no-tls or drop --no-tls1
teachatty helpthe help0
stray argument or unknown flag (teachatty dev, --romm)unknown argument: dev + the help2
pipe mode: input ended and everything was sent—0
pipe mode: the connection dropped midway (even while waiting for input, like a quiet tail -f)connection closed: + the reason1
pipe mode: a line was over 1 MBconnection closed: websocket: close 1009 (message too big)1

In full screen, the terminal is always restored before the message appears.

Pipe mode

When stdin isn't a terminal, the client doesn't open the screen. It sends the input to the room, echoes each line to your terminal (like tee) and exits when the input ends.

make 2>&1 | teachatty --room ops              # the build output goes to the room
uptime | teachatty --room ops                  # one line, then exit
tail -f /var/log/app.log | teachatty --room ops   # streams until Ctrl+C
batched
Lines that arrive within 100 ms of each other go out as one multi-line message (up to 64 KB), shown with the alias on the first line only. A 5,000-line build log is a single message; slow output (a line every few hundred ms) still goes out line by line, with no visible delay.
echo
Every line also shows in your terminal, empty ones included. Empty lines aren't sent to the room.
size
Up to 1 MB per line, the server's limit. A bigger line is refused: the pipe stops and exits with code 1, saying message too big.
the room
What others send while the pipe runs isn't shown; it's read and discarded.
never opens the screen
Safe in scripts, CI and cron: nobody needs to press Ctrl+C at the end.
no history
If nobody is in the room, the lines are lost.
servers that say no
The client declares pipe mode when it connects (mode=pipe in the URL). A server started with --no-pipes refuses it, and the client exits with the server's reason.
04

Server reference

Flags for the address and for TLS, besides -h (help also shows the help, and any other argument shows the help and exits with code 2, without starting the server). It listens on :7347, on all interfaces, and exposes a single endpoint.

Flags

flagdefaultdescription
--addr:7347where to listen, host:port. No host means all interfaces: --addr :9000. With a host, only that one: --addr 10.0.0.5:7347 listens only on the internal network
--no-tlsoffserve plain ws://, without encryption. For testing
--no-pipesoffrefuse clients in pipe mode, for rooms that want chat and no build logs (403 with the reason). A courtesy rule: a client that doesn't declare pipe mode isn't stopped; the rate limit is what holds back abuse
--max-per-ip10max simultaneous connections from one IP address; 0 = no limit (a company behind NAT reaches the server from a single IP)
--tls-cert, --tls-keyself-signedyour own certificate and key (PEM), e.g. Let's Encrypt's fullchain.pem and privkey.pem. Reloaded when the files change (security)
--versionprint the version and exit

Busy port or bad address: the server exits with the error (address already in use, missing port in address) and code 1, without announcing anything first.

Endpoint

GET /ws?room=<room>&alias=<alias>[&mode=pipe]   upgrade to WebSocket, over TLS (wss://) by default
room
Room name. Any text. Doesn't exist → it's created. Missing or empty → room local.
alias
Name of whoever connects. Any text, even empty. Not validated, and doesn't need to be unique.
mode
pipe when the client runs in pipe mode, absent otherwise. Only read by --no-pipes.
origin
Refuses any request with an Origin header (403). Browsers always send one on WebSocket connections and the teachatty client never does, so web pages can't connect to the server behind a user's back.

Messages

client → server
A text frame with the bare message: hi.
server → client
A text frame with the alias in front: daniel: hi. Goes to every member of the room except the sender.
size
Up to 1 MB per message (maxMessage). Above that, the server answers with close 1009 (message too big) and closes only that connection; the room carries on.
rate
Each connection may send 5 messages a second, with bursts of 20, and 256 KB a second. Above that, the server reads that connection more slowly: nothing is dropped and nobody is disconnected, the sender is just slowed down (TCP backpressure).
history
None. A room is a list of connections; it doesn't store messages.

Room lifecycle

join
If the room isn't in the map, it's created, and the connection becomes a member.
leave
When the connection ends, it leaves the room. An empty room is removed from the map, except local.
default room
local is already in the map when the server starts and is never removed. It's where anyone who doesn't ask for a room goes.
state
Everything in memory. Restarting the server disconnects everyone and wipes all rooms.

Terminal output

Prints teachatty-server listening on :7347 · default room: local · TLS · fingerprint SHA256:... on startup (only after the port is open) and logs upgrade errors with log. If the port is busy or the address is invalid, it exits with that error.

05

Security: TLS and trust on first use

Traffic is encrypted with TLS 1.3 by default, with no certificates for anyone to manage. The model is the one SSH uses with known_hosts. Administrators with a real certificate can use it instead, and clients verify it like a browser.

  1. The server makes its own certificate

    On first start, the server generates a self-signed certificate (ECDSA P-256, valid for 10 years) in ~/.config/teachatty/: server.crt and server.key, the key readable only by its owner. It reuses them on every start and prints the fingerprint: SHA256: + the hash of the public key.

  2. The client trusts it the first time

    The first connection to a server (as written in --server) saves its fingerprint in ~/.config/teachatty/known_servers and shows * trusting new server ..., in gray in the chat, or on stderr in pipe mode. It doesn't ask: a question would block pipes and cron.

  3. And refuses it if it changes

    From then on, only that fingerprint is accepted. A different one means someone is intercepting, or the server's certificate was regenerated: the client refuses to connect and shows both fingerprints. If the change is legitimate, delete that server's line in known_servers.

what it protects
Everyone on the network reading the chat (Wireshark, a compromised switch), and impersonation of a server you've already connected to.
what it doesn't
The very first connection: if it's intercepted, the client trusts the interceptor. Comparing the fingerprint the client shows with the one the server prints closes that gap.
regenerating
Deleting server.crt and server.key makes the server generate a new certificate, and every client will refuse it until they remove the old line from known_servers.
without TLS
--no-tls on both sides goes back to plain ws://. For testing only.
your own certificate
On a real domain, pass --tls-cert and --tls-key (Let's Encrypt's fullchain.pem and privkey.pem, or your company CA's). The client checks every certificate against the machine's trusted authorities first, like a browser: if it verifies for that hostname, it's accepted with no pinning, so renewals with a new key keep working. The server reloads the files when they change, so certbot renewals need no restart.
common mistakes
Two mistakes work at first and break at the first renewal, ~60 days later, so teachatty warns about both right away. Using cert.pem instead of fullchain.pem: without the intermediate certificate the chain doesn't verify; the server warns at startup. Connecting by IP instead of the domain name: the certificate is only valid for the name; the client warns on first use. Also, Let's Encrypt's privkey.pem is readable only by root: use the certbot hook from contrib/ (run as a service), which copies it for the server.
company CA
A certificate from a private company CA only verifies on machines where that CA is installed. Elsewhere it falls back to trust on first use, and each renewal with a new key would trip the "identity changed" check.
code
All of it lives in internal/tlstrust, standard library only (crypto/tls, crypto/x509). The binaries call tlstrust.Server(certFile, keyFile, warn) and tlstrust.Client(server, notice).
06

Architecture

teachatty is two binaries, a server and a client, that talk over WebSocket. Its own code is about 1,250 lines, comments included (plus about 640 lines of tests), because it leans on layers that already do the heavy lifting. Each message goes down through all of them on one side and up through all of them on the other.

The layers

cmd/teachatty · cmd/teachatty-server · internal/*Flags, rooms, aliases, broadcast, the client's screen and TLS trust. This is teachatty's code: main.go, room.go, ui.go and tlstrust.go.our code
gorilla/websocketUpgrade handshake, building and reading frames, masking, ping/pong, close. Provides ReadMessage and WriteMessage.library
tview + tcellClient only. Puts the terminal in raw mode, reads each key, draws the screen (message area, input box, borders) and restores the terminal on exit.library
crypto/tlsTLS 1.3: the handshake, the encryption of every byte after it, and the self-signed certificate (crypto/x509).Go stdlib
net/httpAccepts TCP connections, creates a goroutine for each one, reads the HTTP request and calls the /ws handler.Go stdlib
Go runtimeSchedules thousands of goroutines on a few threads. A goroutine blocked in ReadMessage doesn't hold a thread: the runtime waits on the sockets with epoll and wakes only those that received data.runtime
Linux kernelSockets as files (fd), send and receive buffers, TCP (ordering, retransmission, flow control), IP.operating system
networkNetwork card, cable or Wi-Fi, switch. Or the loopback interface, when everything runs on one machine.hardware

Repository layout

cmd/teachatty-server/main.go       server: /ws handler, rooms map
cmd/teachatty-server/room.go       Room: join, leave, broadcast
cmd/teachatty-server/main_test.go  tests (go test ./...)
cmd/teachatty/main.go              client: flags, connection, receiving
cmd/teachatty/pipe.go              client: pipe mode
internal/ui/ui.go                the client's screen (tview)
internal/tlstrust/               TLS: certificate, trust on first use
internal/version/                --version
contrib/                         systemd unit and certbot hook
docs/                            this site and this documentation
roadmap.md                       next steps and decisions
features.md                      log of what shipped
dependencyversionused for
gorilla/websocketv1.5.3client and server
rivo/tviewv0.42.0the client's screen
gdamore/tcell/v2v2.8.1terminal (under tview)
rivo/unisegv0.4.7text width on screen
golang.org/x/termv0.28.0telling whether stdin is a terminal

A few more indirect ones (x/sys, x/text, ...). All pure Go: each side compiles to a single binary with no system dependencies.

The path of a message

Daniel and Mateus are in room dev. Daniel types hi and presses Enter. The animated, step-by-step version is on the site.

  1. Key by key

    Daniel's terminal is in raw mode. tcell hands each key to tview's event loop, and the text sits in the input box. On Enter, the client calls send, shows daniel: hi on its own screen and clears the box (client internals).

  2. Masked frame

    WriteMessage builds a masked text frame and the kernel sends it over the TCP socket (anatomy of a frame).

  3. Daniel's goroutine on the server wakes up

    It was blocked in ReadMessage. It receives hi and builds daniel: hi (server internals).

  4. Broadcast in the room

    It locks only room dev's mutex and writes to each member except Daniel himself (locks).

  5. Mateus's receiving goroutine wakes up

    It calls screen.Print, which queues the line for tview. The event loop writes the message and redraws, without touching what Mateus is typing.

07

The connection: TCP, then HTTP, then WebSocket

Before any message, the client opens a connection that goes through three protocols, one on top of the other. WebSocket starts as an ordinary HTTP request and, once the server answers 101, that same TCP socket stops speaking HTTP for good.

With TLS, the default, a TLS 1.3 handshake happens right after TCP, and everything below (the HTTP request, the WebSocket frames) travels inside it, encrypted. The diagrams show the bytes as they are inside that tunnel, or on the wire with --no-tls.

From flags to URL

Users never type a URL. The client builds it from the flags, and the server reads the parameters from it.

you type

teachatty \
  --room dev \
  --alias daniel

client builds

wss://localhost:7347
  /ws?alias=daniel
     &room=dev

server reads

r.URL.Query()
  .Get("room")  // dev
r.URL.Query()
  .Get("alias") // daniel

The conversation on the wire

client server TCP SYN SYN-ACK ACK HTTP GET /ws · Upgrade: websocketSec-WebSocket-Key: (16 random bytes) 101 Switching ProtocolsSec-WebSocket-Accept: (computed proof) WS text frame "hi" (masked) text frame "mateus: hey" … both ways, for as long as the connection lasts
client → server
GET /ws?alias=daniel&room=dev HTTP/1.1
Host: localhost:7347
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
server → client
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

On the server, all of this happens in one line: upgrader.Upgrade(w, r, nil). On the client, inside websocket.DefaultDialer.Dial(...).

The Sec-WebSocket-Accept proof

The server takes the key the client sent, appends a fixed GUID defined in RFC 6455, takes the SHA-1 and encodes it in base64. The client does the same calculation and compares.

This isn't security. It proves the server really speaks WebSocket, and isn't just any HTTP server or a cache along the way answering by mistake. The GUID is the same on every WebSocket server in the world.

calculate it yourself

+ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" → SHA-1 → base64
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
08

Anatomy of a frame

TCP is a stream of bytes with no boundaries: it doesn't know where one message ends and the next begins. WebSocket solves that by putting a 2-to-14-byte header in front of each message. That's what WriteMessage builds and ReadMessage takes apart. Type a message and see the exact bytes that go through the socket.

FIN + opcodeMASK + lengthmasking keypayload
byte 0
FIN=1 · RSV=000 · opcode=0001 (text)
byte 1
MASK=1 · length=2

Client frames are masked: each payload byte is XORed with the 4-byte key. In real life the key is random for every frame; here it's fixed at 37 fa 21 3d so the example stays stable.

Why only the client masks

The rule comes from the RFC: everything from client to server must be masked, and nothing from server to client ever is. The reason isn't encryption, since the key travels with the frame. It protects old proxies: without masking, malicious JavaScript in a browser could craft bytes that a proxy would read as an HTTP request and store garbage in its cache.

Length in 7, 16 or 64 bits

Up to 125 bytes, the length fits in byte 1's 7 bits. With the value 126, the next 2 bytes hold the real length (up to 65,535). With 127, it's the next 8 bytes. Type more than 125 characters in the field above to watch the header grow.

09

Server internals

One process, many goroutines. net/http's Accept loop receives each new TCP connection and runs go c.serve(...): the connection gets its own goroutine, which runs the /ws handler for as long as the person stays in the chat.

live goroutines with three people connected
main · Accept loopwaits for the next connectionAccept()
/ws handler · danielconn = fd 5 · room = devReadMessage()
/ws handler · mateusconn = fd 8 · room = devReadMessage()
/ws handler · joãoconn = fd 9 · room = geralReadMessage()

Each goroutine has its own local variables: conn, name, alias, msg. What they share, the rooms map and each *Room, needs a lock.

the server's /proc/PID/fd (measured)

4socket:[2305506] · port 7347 listening
5socket:[2306269] · connection to client 1
6anon_inode:[eventpoll] · the runtime's epoll
7anon_inode:[eventfd] · wakes the epoll
8socket:[2297779] · connection to client 2

Each *websocket.Conn in the map is, underneath, one of these numbers. WriteMessage ends in a write() on that descriptor, the same system call used to write to a file.

Memory

rooms map[string]*Room · guarded by mu (global)
"dev"→ 0xc000a1
Room
mu sync.Mutex // zero value = unlocked
members map[*websocket.Conn]string
connDaniel→ "daniel"
connMateus→ "mateus"
"geral"→ 0xc000b7
Room
mu sync.Mutex
members
connJoao→ "joao"

The map stores pointers. Daniel's and Mateus's goroutines get the same address and work on the same Room. If the struct were stored by value, each would get a copy, with its own members and mutex, and neither would see the other.

The key in members is the connection, not the alias. Each connection is a unique object in memory, so two people never collide, even with the same alias. And it's exactly what broadcast needs to know where to write.

A room doesn't store messages. It's a contact list. A message passes through the sender's goroutine and goes out right away, one copy per connection. Whoever joins later doesn't see what was already said.

The code

cmd/teachatty-server/room.go
// writeWait is how long one member gets to take a message. A member that doesn't read
// (a laptop that went to sleep, a network that dropped without closing the connection, a
// teachatty pipe suspended with Ctrl+Z, a badly written client) fills its network buffers and then
// blocks every write to it; without a limit, broadcast would wait forever while holding
// the room's lock, and the whole room would freeze. A var, not a const, so the tests can
// shorten it.
var writeWait = 5 * time.Second

// Room has its own mutex: one room broadcasting doesn't block the others
type Room struct {
	mu      sync.Mutex
	members map[*websocket.Conn]string // connection -> alias
}

func (r *Room) join(conn *websocket.Conn, alias string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.members[conn] = alias
}

// leave removes the connection and reports whether the room is now empty
func (r *Room) leave(conn *websocket.Conn) (empty bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.members, conn)
	return len(r.members) == 0
}

func (r *Room) broadcast(myOwnConn *websocket.Conn, msg []byte) {
	r.mu.Lock()
	defer r.mu.Unlock()
	for c := range r.members {
		if c == myOwnConn {
			continue
		}
		c.SetWriteDeadline(time.Now().Add(writeWait))
		if err := c.WriteMessage(websocket.TextMessage, msg); err != nil {
			// too slow or already gone: drop it so the room keeps going. Closing the
			// connection also wakes its own goroutine (blocked in ReadMessage), which then
			// leaves the room the normal way; deleting it here (allowed while ranging over
			// a map) stops the next broadcasts from waiting on it again.
			delete(r.members, c)
			c.Close()
		}
	}
}
cmd/teachatty-server/main.go · handleWS
func handleWS(w http.ResponseWriter, r *http.Request) {
	// --no-pipes: the teachatty client declares pipe mode in the URL. A courtesy rule, not
	// a security boundary: a client that doesn't declare it isn't stopped (the rate limit
	// is what holds back abuse); it keeps teammates' build logs out of a chat room.
	if noPipes && r.URL.Query().Get("mode") == "pipe" {
		http.Error(w, "this server doesn't accept pipe mode (it was started with --no-pipes)", http.StatusForbidden)
		return
	}

	// one IP can't hold more than --max-per-ip connections (see limits.go)
	release, ok := acquireIP(r)
	if !ok {
		http.Error(w, "too many connections from your address", http.StatusTooManyRequests)
		return
	}
	defer release()

	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	defer conn.Close()

	// a message over the limit: ReadMessage returns an error, gorilla answers with
	// close 1009 ("message too big") and this connection leaves the loop and the room like any other
	conn.SetReadLimit(maxMessage)

	// no room in the URL (old client, another program): goes to the default room
	name := cmp.Or(r.URL.Query().Get("room"), defaultRoom)
	alias := r.URL.Query().Get("alias")

	// joining a room that doesn't exist creates it
	mu.Lock()
	room := rooms[name]
	if room == nil {
		room = &Room{members: map[*websocket.Conn]string{}}
		rooms[name] = room
	}
	room.join(conn, alias)
	mu.Unlock()

	msgs, bytes := limiters()
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			break
		}
		// over its budget, this connection waits here before the next read: nothing is
		// dropped, the sender is just slowed down (see limits.go)
		if msgs.Wait(r.Context()) != nil || bytes.WaitN(r.Context(), len(msg)) != nil {
			break
		}
		// relay the message to the others in the same room
		room.broadcast(conn, []byte(alias+": "+string(msg)))
	}

	// leave the room; an empty room stops existing, except the default one
	mu.Lock()
	if room.leave(conn) && name != defaultRoom {
		delete(rooms, name)
	}
	mu.Unlock()
}
10

Two locks, one order

The server has two levels of sync.Mutex. The global lock mu guards only the rooms map and is used for joining and leaving. Each room's lock, r.mu, guards its member list and is the only one broadcast uses. That way, a busy room doesn't slow down the others.

The rule that prevents deadlock: always global first, then room. If one goroutine locked global→room and another room→global, each could hold the lock the other is waiting for, forever.

Why joining and leaving use the global lock: the ghost room

without the global lock

joãoroom := rooms["dev"] → finds the room
mateusleave → room empty → delete(rooms, "dev")
joãoroom.join(...) → joins a room that's no longer in the map
✗ joão is alone in a room nobody else can find

with the global lock

mateusmu.Lock · leave · delete · mu.Unlock
joãowaiting for mu…
joãomu.Lock · rooms["dev"] is nil → creates a new room · join
✓ the two operations happen one after the other

Measured result

latency in the small room · 50,000 people in the big room

0 150 ms 300 ms 450 ms 600 ms global lock average 265 ms worst 568 ms per-room lock average 0.111 ms · ~2,400× lower worst 0.139 ms

The test: 50,000 connections in one room receiving 10 messages in a row, while two people in a small room exchanged messages. With the global lock, each broadcast in the big room held the whole server for hundreds of milliseconds, and the small room waited in line.

With one lock per room, the green bar barely shows on the chart, and that's the result. Go's race detector (go build -race) found no problems with 30 clients joining and leaving 4 rooms at the same time.

A detail: each room's lock also guarantees gorilla's one-writer-per-connection rule. Each connection belongs to a single room, and only that room's broadcast writes to it.

Holding the lock while writing has a cost: a member that stops reading would make broadcast wait forever, freezing the room. So each write has a 5-second deadline (writeWait); a member that misses it is removed and its connection closed.

11

When someone leaves

Leaving is the way in, backwards, and most of it happens on its own.

  1. The connection ends

    The person presses Ctrl+C, closes the terminal or the network drops. The client restores the terminal and closes the socket (client internals).

  2. ReadMessage returns an error

    That person's goroutine on the server, blocked in ReadMessage, wakes up with an error. The break leaves the loop.

  3. leave and, if needed, delete

    Under the global lock: room.leave(conn) removes the connection from members and reports whether the room is now empty. If it is, and it isn't local, delete(rooms, name) removes the room.

  4. defer conn.Close()

    The handler returns, and the defer scheduled right after the upgrade closes the connection on the server side.

  5. The goroutine ends

    It only existed to run that handler. Its stack, a few KB, is freed.

12

Client internals

The client is a full screen, like vim or htop: messages on top, input box at the bottom. Drawing is done by tview, a terminal widget library built on tcell. Everything tview lives in the internal/ui package. The client's main.go only knows four functions.

Mateus's screen in room dev

The double border marks the box with focus, which never leaves it. The empty line at the end of the message area is on purpose: it separates the last message from the border.

what main.go sees

functionwhat it does
ui.New(room, alias, send)builds the screen. send is called on every Enter with the typed text
screen.Print(line)shows a received message. Safe to call from another goroutine
screen.Run()takes over the terminal and only returns when the screen closes
screen.Stop()closes the screen, for when the connection drops

Why typing no longer gets overwritten

before: canonical mode + fmt.Println

kernelholds "looking a" and echoes it; the program sees nothing
recvfmt.Println("daniel: hi") writes wherever the cursor is
screenlooking adaniel: hi
✗ the program doesn't know what you've typed, so it can't redraw

now: raw mode + tview

tcelleach key arrives immediately; "looking a" lives in the box, in the program's memory
recvscreen.Print("daniel: hi") → tview's queue
screenthe event loop writes the message and redraws everything, box intact
✓ a single goroutine draws, and it knows everything on screen

The client's goroutines

main · tview event loophandles keys and the update queue, redrawsRun()
terminal readertcell reads stdin in raw mode and produces key eventsread(stdin)
receiverthe go func() in main.goReadMessage()

Only the event loop touches the screen. Two goroutines writing to the same TextView at once would be a race condition. That's why the receiver doesn't draw: Print wraps the change in a function and uses QueueUpdateDraw to queue it. The loop runs the function on its turn and redraws.

Only the event loop writes to the connection. send is called on Enter, inside the loop. So gorilla's one-writer-per-connection rule holds on the client side too.

Following the end

tview's TextView doesn't follow new text by default. Once the screen fills up, new messages stay hidden below until someone scrolls. This showed up in a live test, when the conversation grew past one screen. One call to ScrollToEnd() at creation turns on "follow the end".

With PgUp, tview stops following, so you can read older messages without being pulled back on every new one. Any change in the input box calls ScrollToEnd() again.

Scrolling to the end with PgDn doesn't turn following back on: that's how tview works. Typing does.

keys

keydoesfollows the end?
Entersendsyes
PgUpup one pageno, stops following
PgDndown one pagestays as it was
typingwrites in the boxyes, jumps to the end
Ctrl+Cquits—

The growing input box

internal/ui/ui.go · on every change in the box
_, _, width, _ := input.GetInnerRect()
lines := uniseg.StringWidth(input.GetText())/width + 1
layout.ResizeItem(input, min(lines, 5)+2, 0)
if lines <= 5 {
	input.SetOffset(0, 0)
}
text in a 20-column boxlines
5 characters1
20 characters (fills the line, the cursor wraps)2
84 characters5
200 characters5, scrolls inside the box

Screen width, not bytes. StringWidth counts columns: ã takes 2 bytes and 1 column, an emoji takes 2 columns. Dividing by the box width gives how many full lines the text takes. The +1 is the current line, where the cursor is. The +2 is the borders.

Breaks at any character. The box uses SetWordWrap(false): breaking only between words, a long word would jump whole to the next line and the calculation would no longer be exact. In the box, "deplo" can end up on one line and "y" on the next. In the message area, lines still wrap between words.

Pasted text. It arrives faster than the redraw, while the box is still small, and the box scrolls inside to show the cursor. Once it has grown, SetOffset(0, 0) resets the scroll to the top. Without it, the first line stayed hidden.

Exiting without breaking the terminal

In raw mode, the terminal doesn't echo what you type and Enter doesn't start a new line. If the program exits without restoring normal mode, the person is left with a broken shell until they type reset blind. What restores it is Run(), when it returns. That's why no exit path can skip Run(), and log.Fatal does: it calls os.Exit, which ends the process on the spot.

Ctrl+C

tviewcatches Ctrl+C and stops the event loop
mainRun() restores the terminal and returns
maindefer conn.Close() → the server sees the exit (when someone leaves)
✓ exits with code 0, terminal intact

the connection drops

recvReadMessage errors → readErr = err → close(closed) → screen.Stop()
mainRun() restores the terminal and returns
mainclosed is closed → log.Fatal("connection closed: ", readErr)
✓ the message appears on an already-restored terminal, code 1

Colors per alias

A hash of the alias, not a random pick. The FNV-32a hash of the alias picks one of 12 colors. The same alias gets the same color on every client, without the server or the protocol knowing anything about colors. Only the alias is colored; the text stays in the normal color.

Your message, in bold. This applies to the lines you sent, not to the alias. If someone else joins with your alias, they show up colored, not bold: you can tell.

Color tags become text. To color text, tview starts interpreting tags like [red]. So everything that comes from outside (alias, text, room name in the title) goes through tview.Escape. Whoever sends [red]hi shows up as [red]hi, literally.

The server sends alias: text as a single string, and the client finds the alias by splitting at the first ": ". An alias containing ": " gets split wrongly. That goes away once messages are structured.

the palette

#e06c75#e5a04b#d7c25a#98c379#4fd6a0#56b6c2#61afef#a3a0ff#c678dd#f08fc0#d19a66#7fb3d5

Mid tones, readable on dark and light terminals. With 12 colors, collisions happen: in a room of 5 people, the chance that two share a color is about 60%.

Pipe mode internals

main · pipe()reads stdin line by line, echoes to the terminal, sendsReadString()
room readerreads and discards what others sendReadMessage()

No screen, no tview: just two goroutines. One is the only writer to the connection, the other the only reader. gorilla's rule again.

Why read what won't be shown. The room's messages keep arriving at the pipe. If nobody reads them, the network buffers fill up and the server's broadcast, which writes to one member at a time under the room's lock, gets stuck waiting on it. The whole room stalls.

Noticing failures. A write to the connection "succeeds" even after the server has refused something, because it only goes into a buffer. The reader goroutine is what sees the close, so it records the reason in readErr before closing done. The pipe checks done before each line and at the end, and exits with code 1 and the reason (for example close 1009 (message too big)).

End of input. When ReadString returns io.EOF, the pipe sends the WebSocket close frame and waits for the server to answer (up to 2 s). Exiting immediately could lose the last lines.

flooded room: 5,000 messages of 2 KB

who else is in the roomreached the others
a slow teachatty pipe5,000 of 5,000
a client that never reads2,082 of 5,000, room stalled

The second case was the room freezing for good. The server now gives each member 5 seconds to take a message and drops the ones that can't (limitations), so the room only pauses. The teachatty client never gets there, because it always reads.

teachatty's look

The logo's colors. Borders in mint (#72e3a6), the title in amber (#f2b04f), over the terminal's own background and text color instead of forced black and white, so light terminals look right too. Set once in tview.Styles before any widget is created, since widgets copy those defaults.

The welcome banner is written with Welcome, before Run, like Notice: the teacup in text plus up to four lines of information that main.go fills in (server, room, TLS version and fingerprint from the live connection, keys).

Timestamps are the local time each message arrived, added by the client in format: the server sends no time at all.

Mentions are a regular expression built from your alias (@alias followed by the end or a character that can't be part of a name, any case), checked only on other people's messages. The bell is Beep on the tcell screen. tview doesn't expose its screen, so Run creates it and hands it over with SetScreen, keeping a reference; the beep runs inside the event loop, like every other change to the screen.

The code

cmd/teachatty/main.go · after opening the connection
// stdin is not a terminal (command | teachatty): pipe mode, no screen
pipeMode := !term.IsTerminal(int(os.Stdin.Fd()))

// build wss://SERVER/ws?room=...&alias=... from the flags (ws:// with --no-tls)
// dereference the pointer to get the string
scheme := "wss"
if *noTLS {
	scheme = "ws"
}
query := url.Values{"room": {*room}, "alias": {*alias}}
// pipe mode says so, so a server started with --no-pipes can refuse it
if pipeMode {
	query.Set("mode", "pipe")
}
u := url.URL{
	Scheme:   scheme,
	Host:     host,
	Path:     "/ws",
	RawQuery: query.Encode(),
}

// TLS trust (trusted on first use, then pinned) lives in internal/tlstrust.
// A notice like "trusting new server ..." goes into a buffer and is shown below,
// once we know whether it goes to the screen or to stderr.
var notice bytes.Buffer
dialer := *websocket.DefaultDialer
if !*noTLS {
	cfg, err := tlstrust.Client(host, &notice)
	if err != nil {
		log.Fatal(err)
	}
	dialer.TLSClientConfig = cfg
}
conn, resp, err := dialer.Dial(u.String(), nil)
if err != nil {
	// the server refuses some connections on purpose, before the WebSocket handshake
	// (403: pipes not allowed; 429: too many connections from your IP), and says why
	// in the response body: show that reason instead of a generic "bad handshake"
	if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests) {
		reason, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		log.Fatal("the server refused the connection: ", strings.TrimSpace(string(reason)))
	}
	log.Fatal(tlstrust.DialHint(err, !*noTLS))
}
defer conn.Close()

if pipeMode {
	os.Stderr.Write(notice.Bytes())
	pipe(conn)
	return
}

// the screen calls this on every Enter; only the screen's goroutine writes to the
// connection, so gorilla's one-writer-per-connection rule still holds
screen := ui.New(*room, *alias, func(text string) {
	conn.WriteMessage(websocket.TextMessage, []byte(text))
})
// the welcome banner: where you are, how the connection is secured, the keys
security := "not encrypted (--no-tls)"
if tc, ok := conn.NetConn().(*tls.Conn); ok {
	state := tc.ConnectionState()
	security = "encrypted · " + tls.VersionName(state.Version)
	if len(state.PeerCertificates) > 0 {
		fp := tlstrust.Fingerprint(state.PeerCertificates[0])
		security += " · " + fp[:min(len(fp), 19)] + "…"
	}
}
screen.Welcome(
	"teachatty · "+host+" · room "+*room,
	security,
	"Enter send · PgUp/PgDn scroll · Ctrl+C quit",
	"mention someone with @name to ring their bell",
)
if notice.Len() > 0 {
	screen.Notice(strings.TrimSpace(notice.String()))
}

// empty channel to signal that the connection dropped; the receiving goroutine closes it
closed := make(chan struct{})
var readErr error // why it dropped; written before close(closed), read after

// receive messages in parallel while you type
go func() {
	for {
		_, msg, err := conn.ReadMessage()
		if err != nil {
			// log.Fatal can't be used here: it exits without restoring the terminal.
			// Tell main and close the screen; main is the one that exits.
			readErr = err
			close(closed)
			screen.Stop()
			return
		}
		screen.Print(string(msg))
	}
}()

// Run owns the terminal until Ctrl+C or until the connection drops
if err := screen.Run(); err != nil {
	log.Fatal(err)
}

// the terminal is back to normal; now it's safe to report that the connection dropped
select {
case <-closed:
	log.Fatal("connection closed: ", readErr)
default:
}
internal/ui/ui.go · excerpts
// aliasColor picks the color from a hash of the alias: the same alias always gets the
// same color, on any client, without the server knowing anything about colors.
func aliasColor(alias string) string {
	h := fnv.New32a()
	h.Write([]byte(alias))
	return palette[h.Sum32()%uint32(len(palette))]
}

// format builds the line with the alias colored. With colors on, tview interprets
// tags like "[red]" in the text, so everything from outside goes through tview.Escape:
// without it, anyone could send "[red]" and change your screen.
// mine = a message you sent yourself: alias in bold, in the normal text color.
func format(alias, text string, mine bool) string {
	style := "[" + aliasColor(alias) + "]"
	if mine {
		style = "[::b]"
	}
	return style + tview.Escape(alias) + "[-::-]: " + tview.Escape(text)
}

// inside New: the Enter key
case tcell.KeyEnter:
	// Enter sends instead of inserting a newline
	text := input.GetText()
	if strings.TrimSpace(text) != "" {
		send(text)
		// your own message shows up like everyone else's: "alias: text"
		fmt.Fprintln(u.messages, format(alias, text, true))
	}
	input.SetText("", false)
	return nil

// Print shows a received line. Safe to call from another goroutine:
// QueueUpdateDraw hands the change to the screen's goroutine, which redraws.
func (u *UI) Print(line string) {
	// the server sends "alias: text" as a single string; the alias is whatever comes
	// before the first ": ". Without ": ", the whole line is shown uncolored.
	out := tview.Escape(line)
	if alias, text, ok := strings.Cut(line, ": "); ok {
		out = format(alias, text, false)
	}
	u.app.QueueUpdateDraw(func() {
		fmt.Fprintln(u.messages, out)
	})
}
cmd/teachatty/pipe.go
// batching in pipe mode: how long to wait for more lines after the first one, and the
// largest batch. 100 ms is unnoticeable for a human reading the room; 64 KB keeps each
// message far below the server's 1 MB limit.
const (
	batchWait = 100 * time.Millisecond
	batchMax  = 64 << 10
)

// pipe is the screenless mode (command | teachatty): it sends each stdin line to the room,
// echoes the line to the terminal (like tee) and exits when the input ends.
// It never opens the screen on purpose: in a script or cron there's nobody to press Ctrl+C.
func pipe(conn *websocket.Conn) {
	// the room's messages keep arriving and must be read, even if not shown.
	// Without this, the network buffers fill up and the server's broadcast stalls the
	// whole room waiting on us. This goroutine is the only reader; pipe is the only writer.
	// readErr records why reading stopped. It is written before close(done), so whoever
	// sees done closed also sees the right value (same guarantee as "closed" in main.go).
	done := make(chan struct{})
	var readErr error
	// closing is set right before we send our own close frame at the end, so the reader
	// can tell "we're finishing" from "the connection dropped while we wait for input"
	var closing atomic.Bool
	go func() {
		for {
			if _, _, err := conn.ReadMessage(); err != nil {
				readErr = err
				close(done)
				// dropped while main may be blocked waiting for the next input line (a quiet
				// tail -f, or after being suspended and dropped by the server): exit now
				// instead of hanging until a line arrives. No screen here, nothing to restore.
				if !closing.Load() {
					log.Fatal("connection closed: ", err)
				}
				return
			}
		}
	}()
	// the connection dropped or the server refused something (e.g. a line over the limit,
	// close 1009): writes still "work" because they go to a buffer, so the reader is what tells us
	dropped := func() {
		log.Fatal("connection closed: ", readErr)
	}

	// send writes one message, reporting the real reason if the connection is gone
	send := func(text string) {
		select {
		case <-done:
			dropped()
		default:
		}
		if err := conn.WriteMessage(websocket.TextMessage, []byte(text)); err != nil {
			// the real reason usually arrives through the reader (e.g. close 1009, line
			// too big); wait for it briefly before showing the write error
			select {
			case <-done:
				dropped()
			case <-time.After(time.Second):
				log.Fatal("connection closed: ", err)
			}
		}
	}

	// stdin is read in its own goroutine, so the loop below can wait for "the next line
	// or a short pause, whichever comes first". ReadString has no line length limit
	// (bufio.Scanner gives up at 64 KB).
	lines := make(chan string)
	go func() {
		r := bufio.NewReader(os.Stdin)
		for {
			line, err := r.ReadString('\n')
			if line != "" {
				lines <- strings.TrimRight(line, "\r\n")
			}
			if err != nil {
				if err != io.EOF {
					log.Fatal("error reading input: ", err)
				}
				close(lines)
				return
			}
		}
	}()

	// batching: lines that arrive within batchWait of the first one go out together, as
	// one multi-line message of up to batchMax bytes. A 5,000-line build log becomes a
	// handful of messages instead of flooding the room (and the server's rate limit).
	var batch []string
	size := 0
	flush := func() {
		if len(batch) > 0 {
			send(strings.Join(batch, "\n"))
			batch, size = nil, 0
		}
	}
	timer := time.NewTimer(batchWait)
	timer.Stop()
	for open := true; open; {
		select {
		case line, ok := <-lines:
			if !ok {
				open = false
				break
			}
			fmt.Println(line) // echo: you still see the command's output
			if strings.TrimSpace(line) == "" {
				continue // an empty line is echoed, but not sent to the room
			}
			if size > 0 && size+len(line) > batchMax {
				flush()
			}
			if len(batch) == 0 {
				timer.Reset(batchWait)
			}
			batch = append(batch, line)
			size += len(line) + 1
		case <-timer.C:
			flush()
		}
	}
	flush()

	// close properly: send the close frame and wait for the server to answer (then the
	// reader goroutine ends and closes done). Exiting without this can lose the last lines.
	closing.Store(true)
	conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
	select {
	case <-done:
		// a normal closure (1000) is the answer to our close; anything else is a problem
		if !websocket.IsCloseError(readErr, websocket.CloseNormalClosure) {
			dropped()
		}
	case <-time.After(2 * time.Second):
	}
}
13

Capacity in RAM

All of the server's state lives in memory. We measured what that costs by opening thousands of connections in one room and reading the process's VmRSS from /proc.

server RAM × connections

900 MB 600 MB 300 MB 0 0 10k 20k 30k 40k 50k open connections 183 MB 875 MB
connectionsRAM1 msg to whole room
07.6 MB—
1,00028 MB5.7 ms
10,000183 MB59 ms
50,000875 MB309 ms

That's about 17 KB per connection, almost all of it goroutine stack and gorilla's 4 KB read and write buffers. The chat data itself (map, alias) is a few dozen bytes per person. Broadcast time grows linearly, about 6 µs per recipient.

A database? Not yet. What lives in RAM is connection state, which only exists while the process is alive. A database comes with message history, probably pure-Go SQLite, so it stays a single binary.

14

Limitations and security

teachatty is in early development and deliberately bare. These are the known limits today:

The first connection is trusted blindly

Trust on first use protects every connection after the first. If someone intercepts that very first one, the client trusts them. Compare the fingerprint with the one the server prints.

A stuck member pauses its room for 5 s

Broadcast writes to one member at a time, under the room's lock. A member that stops reading (a laptop asleep, a dropped network, a suspended pipe) holds its room for up to 5 seconds, then the server drops it and the room carries on. Before this limit, the room froze for good (measured: stuck at 2,082 of 5,000 messages). A per-connection send queue would remove the pause entirely.

Behind a reverse proxy, everyone is one IP

The per-IP cap reads the address of the TCP connection. Behind a proxy (nginx, a load balancer), every client looks like the proxy: raise --max-per-ip or set it to 0 there.

Aliases aren't verified

Anyone can join as --alias daniel. A password will protect the room, but won't prove who's who inside it.

No history

A message sent to an empty room is lost, and whoever joins later doesn't see the past. Pipe mode depends on this to keep logs.

15

Roadmap

In this order, one feature at a time. The full list, with the decisions made and the open questions, is in roadmap.md. What has shipped is in features.md.

  1. In your package manager

    Signed apt and dnf repositories, the AUR and Homebrew, so updates come with apt upgrade. Then the official Fedora and Debian repositories, and Ubuntu with them.

  2. Access control

    A server-wide token or password-protected rooms, decided after the release. TLS is in place, so a secret travels encrypted; the server stores only a salted hash.

  3. Message history

    SQLite with modernc.org/sqlite. Needed for pipe mode to keep logs and for persistent rooms.