SSH is a protocol that allows secured remote access and system management over an unsecured network. It uses cryptography to authenticate and encrypt connections between the devices.
Most engineers have used SSH tools to connect to and manage remote machines, often on a daily basis. It’s a foundational part of internet infrastructure, and the modern digital world would look very different without it.
For a while now, I've wanted to take a deeper look at how SSH works under the hood. Despite using it regularly, mostly with the OpenSSH package, I hadn’t had the time to properly explore its inner workings. But last week, after putting it off for quite some time, I finally made room in my schedule to work on this.
My experience with SSH was primarily as a user: opening terminals, setting up port forwarding, and occasionally tweaking the sshd_config
file to suit specific needs. Like many engineers, I've always found it a bit magical. You type a command, and boom: you are connected to a remote machine. I wanted to understand what happen behind the scenes.
When I want to learn something new, my usual approach is to study the theory first, reading articles and documentation or doing an online course depending on the case, and then try to wrap up that knowledge by building a small project. Getting your hands dirty is, in my opinion, one of the best ways to truly internalize a concept.
In this reading, I'll walk you through how I built a minimal SSH server as a weekend project. If you're just here for the code, feel free to jump to the GitHub link at the end. The README file has all the information you need of how to compile and use the application.
I'll also share my thought process as a software engineer when tackling a new project. Keep in mind this was a time-boxed experiment, so I made a few trade-offs to keep things simple and focused. Still, if you're a college student or someone learning to code, I hope this gives you some insight into how small projects can complement your learning journey.
Why An SSH Server?
One option was to build an SSH client instead, which would've been an interesting choice too. But creating something that could run as a service, managed by tools like systemd
on Linux, sounded more appealing.
After thinking about it, I decided to explore the server side of the SSH protocol and write a small SSH server. I wanted to go deep enough to understand the core message flow that makes SSH connections work.
So I spent a few days reading about the SSH protocol in my free time. After that, I scoped out the requirements for my weekend project.
Overview of SSH Channel Types and Requests
This section provides a high-level overview of key aspects of the SSH protocol, focusing on the events and messages I needed for this project.
The SSH protocol runs over TCP and supports multiple channel types, each serving different purposes. Some channels handle data streams, while others are designed to process request messages.
SSH Channel Types
Each channel type defines what the channel is used for:
- session: The most common channel type. Used for remote shells, and command execution and subsystems.
- x11: Used for X11 forwarding. Allows remote GUI apps to display locally via the X Window System.
- forwarded-tcpip: Used for local port forwarding. Data sent to a local port is forwarded through the SSH tunnel to a destination host/port.
- direct-tcpip: Used for remote port forwarding. The remote SSH server connects to a specified address/port and sends data through the tunnel back to the client.
In addition to these, OpenSSH defines some extended channel types that are not part of the official SSH protocol:
direct-streamlocal@openssh.com
: Used for forwarding Unix domain sockets. It's similar to direct-tcpip, but forwards socket paths instead of TCP ports.forwarded-streamlocal@openssh.com
: Used for forwarding Unix domain sockets in the opposite direction.
Channel Requests
The session channel is the main channel type that receives channel requests. Other channels, such as x11, direct-tcpip, and forwarded-tcpip, do not receive channel requests. Those are primarily used for raw data forwarding.
Here is a list of the most common requests types:
- pty-req: Requests a pseudo-terminal for interactive shell.
- shell: Starts an interactive shell session.
- exec: Executes a single command.
- subsystem: Requests a subsystem (e.g., sftp).
- x11-req: Requests X11 forwarding for the session.
- env: Sets environment variables.
- window-change: Notifies of window size changes.
- signal: Sends POSIX signals (e.g., SIGINT) to the remote process.
- exit-status: Sent by server to indicate process exit code.
- exit-signal: Sent by server to indicate process was terminated by signal.
Project Scope and Technical Approach
This section describes the project scope and the requirements I chose to include. In real-world projects, features typically go through several rounds of refinement before development begins. The final output often consists of a set of well-defined tickets, each with specific tasks and clear acceptance criteria.
Features
The goal was to build a very minimal SSH server with support for two use cases: executing a single remote command and starting an interactive shell session.
If you are not familiarized with this terms, when using the OpenSSH client, you can run a remote command like this:
ssh <user>@<address> <command>
ssh myuser@x.x.x.x echo hello world
Or for starting an interactive shell you can omit the command as follows:
ssh <user>@<address>
ssh myuser@x.x.x.x
I decided to add support for only these two use cases. While adding features like port forwarding might be fun in the future, for this initial version I wanted to focus on the most basic functionality.
The server must close the sessions properly once the remote command finishes, or when the user exits the shell or terminates the process.
Authentication
SSH typically supports two main authentication methods: password authentication and public key authentication.
At first glance, password authentication might seem easier to implement. However, it actually requires handling interactive password prompts, which adds complexity. To keep things simple, I decided to go with public key authentication for this initial version.
With this approach, I just need to generate an SSH key pair and provide an authorized_keys
file. This file tells the server which public keys are allowed to authenticate.
To connect, the user must specify their private key explicitly:
ssh -i <path-to-private-key> <user>@<address>
Note: It might seem that we are sending the private key to the server, but actually not. The SSH client derives the public key from the private key, which is what actually gets sent to the server for authentication.
SSH server configuration
I initially considered creating a simplified version of /etc/ssh/sshd_config
, the configuration file used by the OpenSSH server. But for this minimal project, I only needed a few basic settings to customize the application.
So, I went with a simple YAML file that gets read at startup. This allows the application to specify things like the network interface and port to listen on, or which users are allowed to connect, and that is more than enough for the scope of this project.
Access control
I decided to implement an allowlist-based access control model using an authorized_users
setting in the config file. With this approach, only the users listed there are allowed to connect.
In a typical SSH setup, the server reads each user's ~/.ssh/authorized_keys file, and you can add a public key for a particular user with the ssh-copy-id
command. But for simplicity, this implementation reads a single authorized_keys
file specified in the config. That also means multiple users can authenticate using the same key, as long as they possess it.
Additionally, the application should read the /etc/passwd
file to ensure that the user exists, have a home directory and a default login shell.
Concurrency
The application needs to allow multiple clients connected at the same time. For this minimal version, we can skip using a thread pool or any advanced scheduling, but the design must still support concurrent usage from the beginning.
Testing
At a minimum, there should be tests covering the most essential scenarios:
- Can a user connect with a valid key?
- Does the server reject unknown users or invalid keys?
- Can the server execute a single command correctly?
- Can the server start an interactive shell session?
- Can the server handle multiple simultaneous user connections?
Language
As the title already suggests, this project was written in Go. It's a solid choice for scripting, command-line tools, small APIs, socket programming, and services that run as background processes on the Operating System.
It offers excellent built-in support for networking and concurrency, making it a natural fit for implementing an SSH server. And since it’s a compiled language, packaging and running the final application is simple and efficient.
Coding phase
Since this project focuses only on executing single commands and starting interactive shells, the only channel type I needed to implement was the session one. I didn't have to support every possible request type, but at least the essentials: exec, shell, and pty-req.
High-Level Steps
Here is what the application needs to do:
- Read the configuration file, which defines allowed users, the listening address, and the port.
- Read information about the authorized users, like uid, gid, home directory and default login shell.
- Initialize the SSH server using these settings.
- Load the server’s private key, which acts as the host key. (In OpenSSH, these are typically located in
/etc/ssh/
.) - Start a TCP listener on the configured interface and port.
- Ignore non-session channels, since only session is supported in this version.
- Handle exec, shell, and pty-req requests on session channels.
- For exec requests, run the provided command and return the output.
- For interactive sessions, pty-req usually arrives before the shell request, so save the terminal info to be used when launching the shell.
Challenges
Most of the implementation went smoothly, but there were a few tricky areas:
- Managing
stdout
andstderr
from the command or shell process correctly without blocking the stream of data. I ended up using separate Go routines to stream output back to the client. - Minor issues parsing SSH keys due to a mistake in the
authorized_keys
file. I used a wrong format for the public keys. - Implementing PTY handling, especially attaching the shell to a pseudo-terminal to support features like prompts and signal handling. This feature was not implemented completely, proper signal handling and terminal modes is pending.
- Had some issues closing the session correctly. It was not working correctly at first, causing the connection to freeze on the client side. It was being caused by a channel not being closed properly.
- Commands must be executed as the connecting user. To achieve this I required to add
cap_setuid
andcap_setgid
capabilities to the binary, which was not part of my plan. Without those capabilities the application does not have enough permissions to run commands as other users. - While writing integration tests, I found that OpenSSH is slightly more permissive than the Go SSH library when handling certain protocol errors from the server. In some cases, the code worked with the OpenSSH client but failed with the Go SSH library. But this was actually good, as it helped me to improve the implementation to better align with the SSH standard.
Eventually, I got everything working. I took inspiration from a couple of excellent resources to solve some of the harder parts:
- Gopher Academy (2015) - Building an SSH Server in Go
- Gliderlabs (2025) - Gliderlabs SSH Package
Source code
If you want dive into the code it's available on my Github. Feel free to explore, clone it, or use it as a reference for your own experiments.
Bibliography
- Cloudfare - 2025 - What is SSH
- Digital Ocean - 2025 - SSH essentials: Working with ssh servers clients and keys
- The Internet Society - 2006 - RFC 4250
- The Internet Society - 2006 - RFC 4251
- The Internet Society - 2006 - RFC 4252
- The Internet Society - 2006 - RFC 4253
- The Internet Society - 2006 - RFC 4254
Top comments (0)