From Modem to SSH: Building a Telnet/SSH Server in Java
June 18, 2026
In the 90s, a BBS was a modem bank and a phone number. Callers dialed in at 2400 baud, negotiated a connection, and got a text interface. The protocol was Telnet — a standard from 1969 that’s still surprisingly useful today.
But nobody uses modems anymore. Today’s BBS callers connect over the internet, either through raw Telnet (port 2323, a nod to the original) or SSH. Building both into a Java BBS means dealing with two completely different networking stacks, terminal negotiation quirks, and a class of bugs that only appear when real clients connect.
This is the story of how Phosphor BBS handles both.
Telnet: The Old Protocol That Won’t Die
Telnet is raw TCP with a thin negotiation layer. When a client connects, the server sends IAC (Interpret As Command) sequences to agree on options: who echoes characters, what the terminal size is, what terminal type the client is running.
The negotiation happens in the first bytes of the connection:
// Send Telnet negotiation on connect
ctx.writeAndFlush(Unpooled.wrappedBuffer(new byte[]{
IAC, WILL, OPT_ECHO, // Server will echo
IAC, WILL, OPT_SUPPRESS_GA, // Suppress Go-Ahead
IAC, DO, OPT_NAWS, // Ask client for window size
IAC, DO, OPT_TTYPE, // Ask client for terminal type
IAC, DO, OPT_NEW_ENVIRON // Ask for NEW-ENVIRON (RFC 1572)
}));
Each IAC sequence is a 2-byte command followed by an option code. IAC WILL ECHO means “I’ll handle echoing, you don’t need to.” IAC DO NAWS means “please tell me your window size.” The client responds with IAC WILL NAWS followed by a subnegotiation block containing the actual dimensions.
We use Netty for the Telnet server — its pipeline model is perfect for this. A single handler (TelnetHandler) sits in the channel pipeline, parses IAC sequences, filters them out of the input stream, and forwards clean bytes to the BBS session:
public class BbsServer {
public void start() throws InterruptedException {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
ServerBootstrap telnetBootstrap = new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new TelnetHandler(factory));
ch.pipeline().addLast(new TailExceptionHandler());
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);
telnetFuture = telnetBootstrap.bind(telnetPort).sync();
}
}
The Echo Problem
Here’s a bug that took three days to track down: on certain CP437 BBS clients (like MuffinTerm), the login screen showed garbage characters in the username field. The user would type “alex” and see “jIACoIACe” — the IAC negotiation bytes were being echoed as visible text.
The root cause: we were sending ANSI escape sequences (enter private mode, clear screen) before the client acknowledged IAC WILL ECHO. The client was still in local echo mode, so it echoed everything — including the IAC bytes — as visible characters.
The fix was a deferral mechanism. No screen output is sent until the client acknowledges IAC WILL ECHO by sending IAC DO ECHO. A 2-second timeout fallback starts the screen even without an ack, so users on broken clients aren’t stuck on a blank screen:
private static final long ECHO_ACK_TIMEOUT_MS = 2000;
// Don't start the screen until echo is acknowledged
if (!echoAcknowledged && !screenStarted) {
echoTimeoutFuture = ctx.executor().schedule(() -> {
if (!screenStarted) startScreen();
}, ECHO_ACK_TIMEOUT_MS, TimeUnit.MILLISECONDS);
}
NAWS: Knowing Your Window Size
Telnet clients report their terminal size via NAWS (Negotiate About Window Size). When a client resizes its window, it sends a new NAWS subnegotiation with the updated dimensions. The server needs to handle this dynamically — the jterm screen buffer and all widget layouts must reflow.
// Parse NAWS subnegotiation: IAC SB NAWS width-high-byte width-low-byte height-high-byte height-low-byte IAC SE
if (option == OPT_NAWS && sub.length >= 4) {
int width = ((sub[0] & 0xFF) << 8) | (sub[1] & 0xFF);
int height = ((sub[2] & 0xFF) << 8) | (sub[3] & 0xFF);
clientWidth = width;
clientHeight = height;
session.setTerminalSize(width, height);
}
We also request TTYPE (terminal type) and NEW-ENVIRON. TTYPE tells us if the client is xterm, vt100, or something exotic. NEW-ENVIRON (RFC 1572) lets a web proxy pass the real client IP through to the BBS, so we can log the actual user address instead of the proxy’s.
SSH: The Modern Path
SSH is a different beast entirely. Instead of raw TCP with a thin negotiation layer, SSH provides encrypted transport, authentication, and a channel multiplexer. We use Apache MINA SSHD — a pure-Java SSH server that integrates cleanly with the BBS session stack.
The SSH server authenticates against the BBS AuthService (the same database-backed auth used by Telnet), then launches a BBS session via a command factory:
public void start() throws IOException {
ensureSecurityProviders();
sshd = SshServer.setUpDefaultServer();
sshd.setPort(port);
var keyFile = Path.of(System.getProperty("user.home"), ".bbs", "ssh_host_ed25519");
var hostKeyProvider = new FileKeyPairProvider(ensureHostKey(keyFile));
sshd.setKeyPairProvider(hostKeyProvider);
sshd.setPasswordAuthenticator((username, password, session) ->
authService.authenticate(username, password));
sshd.setCommandFactory((channel, command) ->
new BbsSshCommand(factory, authService, channel, command, ctx));
sshd.setShellFactory(channel ->
new BbsSshCommand(factory, authService, channel, null, ctx));
sshd.start();
}
One key difference from Telnet: SSH users skip the login screen. They’ve already authenticated via SSH, so we send them straight to the main menu. This is a UX win — no double authentication.
Host Key Generation
The SSH server needs a host key. We generate an Ed25519 key on first run and persist it to ~/.bbs/ssh_host_ed25519:
private Path ensureHostKey(Path keyFile) throws IOException {
if (Files.exists(keyFile)) return keyFile;
Files.createDirectories(keyFile.getParent());
var keyPair = new KeyPairGenerator().generateKeyPair();
var writer = new OpenSSHKeyPairResourceWriter();
try (var os = Files.newOutputStream(keyFile)) {
writer.writePrivateKey(keyPair, null, null, os);
}
// Set permissions to 600
Files.setPosixFilePermissions(keyFile, EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE));
return keyFile;
}
Ed25519 was chosen over RSA for its smaller key size (68 bytes vs 256+ bytes) and faster signing. The key is generated once and reused across restarts, so returning SSH clients don’t get a host key mismatch warning.
PTY Window Resize
SSH clients send window resize events via the PTY WINCH signal. The SSH server catches these and propagates the new size to the jterm terminal, just like NAWS does for Telnet:
// In BbsSshCommand, handle PTY resize
channel.setOnWindowResize((newCols, newRows, newPixelWidth, newPixelHeight) -> {
session.setTerminalSize(newCols, newRows);
session.screen().refresh();
});
Two Protocols, One Session Stack
Both Telnet and SSH funnel into the same SessionFactory, which creates a BbsSession with a jterm terminal stack (Terminal → Screen → GUI). From that point on, the BBS doesn’t care which protocol the caller used — the session interface is identical.
This was a deliberate design decision. The protocol layer is a transport concern. The BBS — screens, menus, chat, boards — is an application concern. Keeping them separate means we could add WebSocket as a third transport without touching any screen code. And we did — the web API server connects via WebSocket using the same session model.
What We Learned
Building a dual-protocol server taught us:
- Telnet is not dead. It’s a simple, reliable protocol that works with every terminal emulator. The IAC negotiation is fiddly but well-documented.
- SSH is worth the complexity. Encrypted transport, proper authentication, and PTY handling give users a modern experience. Apache MINA SSHD makes it manageable in pure Java.
- Deferral is essential. Screen output should wait for negotiation to complete. Sending ANSI codes before the client is ready causes visible garbage.
- NAWS/WINCH is non-negotiable. If you don’t handle terminal resize, your BBS is broken on every modern terminal emulator. Users resize windows constantly.
- 80×24 is a relic. Classic BBSes were hard-coded to 80 columns × 24 rows. If your terminal was different, you got misaligned garbage. Phosphor BBS supports any terminal size — connect from a 200-column wide terminal, a 40-column mobile SSH client, or a 132-column xterm. The jterm widget library reflows layouts automatically on every resize. Tables add or remove columns, panels adjust their widths, text wraps to fit. No minimum, no maximum, no hard-coded dimensions. The BBS looks right whatever you connect with.
The BBS runs on port 2323 (Telnet) and 2222 (SSH). Both are live, both accept connections, and both funnel into the same application stack. From a modem in 1990 to SSH in 2026 — the BBS is back.