Building a Tiny Remote Login Server (With No Security)
In a previous post, we looked at what happens when you press a key in a terminal. As a follow-up, it's instructive to build a miniature SSH-like server—minus all the security. The complete Go code is available on GitHub, but let's walk through the core mechanics of how it works.
The goal is simple: allow a client to connect over TCP and get an interactive shell. Unlike SSH, there's no authentication whatsoever; anyone who can make a TCP connection get a shell. It’s not useful for production, but it’s a great way to understand how terminals and process management work under the hood.
The Server: More Than Just a Pipe
Writing a server that listens on a TCP port and spawns a shell might sound straightforward, but there's a crucial piece of Linux plumbing involved. When a new connection arrives, the server must:
- Create a pseudoterminal (PTY) for the client.
- Start a
bashprocess and attach itsstdin,stdout, andstderrto that PTY. - Relay bytes back and forth between the TCP socket and the PTY.
Why not just point bash's file descriptors directly at the TCP socket? It works, sort of—you can run commands with nc localhost 7778. But you quickly hit two glaring problems that illustrate why a PTY is necessary.
Problem 1: Ctrl+C doesn't work. When you press Ctrl+C in a real terminal, the Linux kernel translates that keypress into a SIGINT signal sent to the foreground process group. If the shell's I/O is just a raw TCP connection, the kernel doesn't know to do this. Sending the byte 0x03 over the wire does nothing.
Problem 2: top fails. Running top in that shell results in a top: failed tty get error. A strace reveals that top is making an ioctl call on its output file descriptor to query terminal information. Since the descriptor points to a socket—not a terminal—the kernel returns an error.
Step 1: Creating a Pseudoterminal
A PTY is like a bidirectional pipe with special powers. It has two ends:
- Pseudoterminal master (PTM): The end we hook up to the TCP connection.
- Slave pseudoterminal device (pts): The end we assign to the shell's
stdin,stdout, andstderr.
Creating one on Linux is a matter of a few system calls. The following Go code (adapted from github.com/creack/pty) shows the process:
pty, _ := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
sname := ptsname(p)
unlockpt(p)
tty, _ := os.OpenFile(sname, os.O_RDWR|syscall.O_NOCTTY, 0)
In plain English, the steps are:
- Open
/dev/ptmxto obtain the master file descriptor. - Get the name of the corresponding slave device (e.g.,
/dev/pts/13) usingptsname. - Unlock the PTY with
unlockpt. While the reason for locking is obscure, it's mandatory. - Open the slave device file to get a file descriptor for it.
All these operations are routed through ioctl system calls, which is how most kernel-terminal interactions happen.
func ptsname(f *os.File) string {
var n uint32
ioctl(f.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n)))
return "/dev/pts/" + strconv.Itoa(int(n))
}
func unlockpt(f *os.File) {
var u int32
// use TIOCSPTLCK with a pointer to zero to clear the lock
ioctl(f.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&u)))
}
Step 2: Attaching Bash
Connecting the PTY to bash is deceptively easy. You just spawn a new process and assign the PTY's slave file descriptor to the process's standard streams:
cmd := exec.Command("bash")
cmd.Stdin = tty
cmd.Stdout = tty
cmd.Stderr = tty
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
There's one subtle requirement here: Setsid: true. If you omit this, Ctrl+C stops working. Why?
The kernel needs to know which process group to signal. The chain of events on a Ctrl+C is:
- The byte
0x03is written to the PTY master. - The kernel identifies the session associated with this PTY.
- Within that session, it finds the foreground process group.
- It sends
SIGINTto every process in that group.
Setsid creates a new session, which makes the new bash process the session's leader and associates the PTY with that session. Without it, the PTY has no session, and the kernel simply ignores your Ctrl+C.
The relevant model from The Linux Programming Interface (Chapter 34) is:
- Every process has a session ID and a process group ID (which may equal its PID).
- A session consists of one or more process groups, all sharing a single controlling terminal.
- A terminal can be the controlling terminal for only one session.
- At any time, one process group in the session is the "foreground" group for the terminal.
SIGINTis delivered to every process in that foreground group.
Process group membership typically follows pipeline structures: commands in a pipe x | y or a command chain x && y share a group.
You can inspect this yourself by listing processes by session and group:
$ ps -eo user,pid,pgid,sess,cmd | sort -k3
Output shows the PID, process group ID, and session ID. You'll notice processes in the same pipeline share group and session IDs.
bork 58080 58080 57922 ps -eo user,pid,pgid,sess,cmd
bork 58081 58080 57922 sort -k3
Step 3: Setting the Window Size
Programs like top and full-screen editors need to know the terminal's dimensions. For this toy server, we hardcode the size to 80x24:
Setsize(tty, &Winsize{
Cols: 80,
Rows: 24,
})
Behind the scenes, this is another ioctl call:
func Setsize(t *os.File, ws *Winsize) {
ioctl(t.Fd(), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(ws)))
}
Step 4: The Data Relay
With steps 1-3 done, the final server routine is to ferry bytes between the network and the terminal. A pair of io.Copy calls handles both directions—one copies input from the TCP connection to the PTY master, the other copies output back. They're run concurrently in a goroutine so data flows both ways.
go func() {
io.Copy(pty, conn)
}()
io.Copy(conn, pty)
Once the shell exits, there's a tiny bit of cleanup to close the connection:
go func() {
cmd.Wait()
conn.Close()
}()
The Client Side: Raw Mode and Copying
The client is simpler because it doesn't need to manage processes. There are three main jobs:
- Put the local terminal into raw mode.
- Copy
stdin/stdoutto/from the TCP socket. - Restore the terminal state when done.
"Raw mode" is not a single flag—it's a set of terminal attributes that disable line buffering, echo, and signal processing. It's critical here: in cooked (normal) mode, input is only sent line-by-line when you press enter, which would render an interactive shell useless. You get and set these attributes via—you guessed it—ioctl calls.
func MakeRaw(fd uintptr) syscall.Termios {
// from https://github.com/getlantern/lantern/blob/devel/archive/src/golang.org/x/crypto/ssh/terminal/util.go
var oldState syscall.Termios
ioctl(fd, syscall.TCGETS, uintptr(unsafe.Pointer(&oldState)))
newState := oldState
newState.Iflag &^= syscall.ISTRIP | syscall.INLCR | syscall.ICRNL | syscall.IGNCR | syscall.IXON | syscall.IXOFF
newState.Lflag &^= syscall.ECHO | syscall.ICANON | syscall.ISIG
ioctl(fd, syscall.TCSETS, uintptr(unsafe.Pointer(&newState)))
return oldState
}
Once configured, copying data between the terminal and the TCP socket is identical in spirit to the server loop:
go func() {
io.Copy(conn, os.Stdin)
}()
io.Copy(os.Stdout, conn)
Finally, restoring the original settings is another ioctl:
func Restore(fd uintptr, oldState syscall.Termios) {
ioctl(fd, syscall.TCSETS, uintptr(unsafe.Pointer(&oldState)))
}
Testing It Live
Despite being insecure, the server works. It was briefly exposed on the public internet at tetris.jvns.ca (running a terminal game rather than a shell to avoid abuse). You can test a local copy with netcat, since all it does is pipe bytes:
stty raw -echo && nc tetris.jvns.ca 7777 && stty sane
Known Limitations
This raw byte-relay approach isn't a great protocol. It can't pass along vital metadata like the client's actual terminal window size or type. In this demo, the server hardcodes 80x24, which will leave your terminal in an odd state. Without a more capable protocol (e.g., telnet's), you can't automatically restore it, so closing the terminal tab is often the simplest fix.
The "session" requirement compounds the problem: without proper negotiation, the server has no way to know what session or process group to manage beyond what Setsid sets up. The toy demonstrates the fundamentals, but a production system would need far more control messaging than raw TCP byte copying provides.



