Skip to content

Commit 7b8d52b

Browse files
committed
Dockerize the app, finally.
1 parent f3e8879 commit 7b8d52b

File tree

6 files changed

+150
-25
lines changed

6 files changed

+150
-25
lines changed

Dockerfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
FROM ubuntu
2+
3+
# update
4+
RUN DEBIAN_FRONTEND=noninteractive \
5+
apt-get update && \
6+
apt-get install -y \
7+
python3 \
8+
python3-pip \
9+
python3-venv \
10+
openssh-client
11+
12+
# create ssh keys
13+
RUN ssh-keygen -t rsa -b 2048 -f ~/.ssh/id_rsa -N ''
14+
15+
# app directory for our source files
16+
WORKDIR /app
17+
18+
# install requirements
19+
COPY ./requirements.txt ./requirements.txt
20+
RUN python3 -m venv .venv
21+
RUN ./.venv/bin/pip install -r ./requirements.txt
22+
23+
# copy source files
24+
COPY src/ src/
25+
COPY main.py main.py
26+
27+
CMD ["./.venv/bin/python3", "main.py"]

README.md

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,46 @@
11
# PythonSSHServerTutorial
2+
23
A tutorial on creating an SSH server using Python 3 and the `paramiko` package. It will also cover how to "dockerize" the application using Docker to allow it to be run on other platforms.
34

4-
### Prerequisites
5+
## Prerequisites
6+
57
Applications:
6-
* Python 3.8+
7-
* Docker
8-
* OpenSSH (client and server)
8+
9+
- Python 3.8+
10+
- venv
11+
- Docker
12+
- OpenSSH (client and server)
913

1014
`pip` packages:
11-
* paramiko
15+
16+
- paramiko
17+
18+
## Creating the Application
19+
20+
### Create a `venv`
21+
22+
A virtual environment allows you to separate dependencies used in your app from those globally installed on your local machine. It's probably a good idea to use a `venv` if you plan to redistribute your code.
23+
24+
```sh
25+
python -m venv .env
26+
```
27+
28+
You can activate your environment using the following command:
29+
30+
```sh
31+
./.env/Scripts/activate
32+
```
33+
34+
Once activated, any `python` or `pip` commands you make will be executed using `python` and `pip` executable within your `venv`.
35+
36+
Install the following:
37+
38+
```sh
39+
pip install paramiko
40+
```
1241

1342
### Creating the Shell
43+
1444
Our shell will extend the `cmd` module's `Cmd` class. The `Cmd` class provides us a way to create our own custom shell. It provides a `cmdloop()` function that will wait for input and display output. This class also makes it trivial to add custom commands to our shell.
1545

1646
We start by importing the `cmd` module's `Cmd` class and extending from it in our shell class:
@@ -29,13 +59,13 @@ use_rawinput=False
2959
prompt='My Shell> '
3060
```
3161

32-
| Property | Description |
33-
|-|-|
34-
| `intro` | A one time message to be output when the `cmdloop()` function is called. |
62+
| Property | Description |
63+
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
64+
| `intro` | A one time message to be output when the `cmdloop()` function is called. |
3565
| `use_rawinput` | Instead of using `input()`, this will use `stdout.write()` and `stdin.readline()`, which means we can use any `TextIO` instead of just `sys.stdin` and `sys.stdout`. |
36-
| `prompt` | allows us to use a custom string to be displayed at the beginning of each line. This will not be included in any input that we get. |
66+
| `prompt` | allows us to use a custom string to be displayed at the beginning of each line. This will not be included in any input that we get. |
3767

38-
Now we can create our `__init__()` function, which will take two I/O stream objects, one for `stdin` and one for `stdout`, and call the base `Cmd` constructor.
68+
Now we can create our `__init__()` function, which will take two I/O stream objects, one for `stdin` and one for `stdout`, and call the base `Cmd` constructor.
3969

4070
```py
4171
def __init__(self, stdin=None, stdout=None):
@@ -69,7 +99,7 @@ def do_bye(self, arg):
6999
return True
70100
```
71101

72-
One final thing we can do, just to make things look a little nicer to the client, is override the `emptyline()` function, which will execute when the client enters an empty command.
102+
One final thing we can do, just to make things look a little nicer to the client, is override the `emptyline()` function, which will execute when the client enters an empty command.
73103

74104
```py
75105
def emptyline(self):
@@ -87,6 +117,7 @@ if __name__ == '__main__':
87117
```
88118

89119
When we run the code we should get something like this as output.
120+
90121
```
91122
Custom SSH Shell
92123
My Shell> greet
@@ -98,7 +129,8 @@ See you later!
98129
```
99130

100131
### Creating the Server Base Class
101-
We can now move on to creating the server base class, which will contain functionality for opening a socket, listening on a separate thread, and accepting a connection, where then it will call an abstract method to complete the connection and setup the shell for the connected client. The reason we do this as a base class, and not as a single server class is so we can support different connection types, such as Telnet.
132+
133+
We can now move on to creating the server base class, which will contain functionality for opening a socket, listening on a separate thread, and accepting a connection, where then it will call an abstract method to complete the connection and setup the shell for the connected client. The reason we do this as a base class, and not as a single server class is so we can support different connection types, such as Telnet.
102134

103135
First we need to import some modules and extend the ABC class in our own `ServerBase` class.
104136

@@ -121,12 +153,12 @@ def __init__(self):
121153
self._listen_thread = None
122154
```
123155

124-
| Property | Description |
125-
|-|-|
126-
| `_is_running` | a multithreaded event, which is basically a thread-safe boolean |
127-
| `_socket` | this socket will be used to listen to incoming connections |
128-
| `client_shell` | this will contain the shell for the connected client. We don't yet initialize it, since we need to get the `stdin` and `stdout` objects after the connection is made. |
129-
| `_listen_thread` | this will contain the thread that will listen for incoming connections and data. |
156+
| Property | Description |
157+
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158+
| `_is_running` | a multithreaded event, which is basically a thread-safe boolean |
159+
| `_socket` | this socket will be used to listen to incoming connections |
160+
| `client_shell` | this will contain the shell for the connected client. We don't yet initialize it, since we need to get the `stdin` and `stdout` objects after the connection is made. |
161+
| `_listen_thread` | this will contain the thread that will listen for incoming connections and data. |
130162

131163
Next we create the `start()` and `stop()` functions. These are relatively simple, but here's a quick explanation of both. `start()` will create the socket and setup the socket options. It's important to note that the socket option `SO_REUSEPORT` is not available on Windows platforms, so we wrap it with a platform check. `start()` also creates the listen thread and starts it, which will run the `listen()` function that we will tackle next. `stop()` is even easier, as it simply joins the listen thread and closes the socket.
132164

@@ -176,6 +208,7 @@ Lastly, we create our abstract `connection_function()` function. This will let u
176208
```
177209

178210
### Creating the ServerInterface
211+
179212
[`ServerInterface` Documentation](http://docs.paramiko.org/en/stable/api/server.html)
180213
[demo_server.py from paramiko repository](https://github.com/paramiko/paramiko/blob/master/demos/demo_server.py)
181214

@@ -204,7 +237,7 @@ def check_channel_shell_request(self, channel):
204237
return True
205238
```
206239

207-
I'll go over a little of what I know about these methods. First, we have to understand what a channel is. Channels provide a secure communication route between the client and the host over an unsecure network. Since we are creating an SSH server, we need to be able to create these channels to allow clients to connect to us. For this, we will need to override `check_channel_request()` to return `OPEN_SUCCEEDED` when the `kind` of channel requested is a `session`. Next we need to override `check_channel_pty_request()` to return `True`. This allows our client to interact with our shell. Finally we can override `check_channel_shell_request()` to return `True`, which allows us to provide the channel with a shell we can connect to it (done in the next section).
240+
I'll go over a little of what I know about these methods. First, we have to understand what a channel is. Channels provide a secure communication route between the client and the host over an unsecure network. Since we are creating an SSH server, we need to be able to create these channels to allow clients to connect to us. For this, we will need to override `check_channel_request()` to return `OPEN_SUCCEEDED` when the `kind` of channel requested is a `session`. Next we need to override `check_channel_pty_request()` to return `True`. This allows our client to interact with our shell. Finally we can override `check_channel_shell_request()` to return `True`, which allows us to provide the channel with a shell we can connect to it (done in the next section).
208241

209242
With all of that out of the way, let's override the method that will allow us to use username and password authentication. If you want to use public SSH keys or gssapi authentication instead, you will need to override the corresponding methods found in the `paramiko` documentation link. You should also look at the `demo_server.py` link I provided at the top of this section, which proved to be a valuable resource while creating this tutorial.
210243

@@ -227,6 +260,7 @@ def get_banner(self):
227260
Okay, that wasn't as painful as I thought it would be, so let's get on to the real fun part of this.
228261

229262
### Creating the SSH Server
263+
230264
The `SshServer` class is where things start to get spicy. That said, the class is actually very simple since we are just implementing the `connection_function()` from the `ServerBase` class we created earlier. Let's start by importing some modules we've created, as well as `paramiko`, and create our server class which will inherit from `ServerBase`.
231265

232266
```py
@@ -272,8 +306,9 @@ def connection_function(self, client):
272306
pass
273307
```
274308

275-
# Ruuning the SSH Server
276-
Finally we can test all of our code up to this point. First we import our `SshServer` class we just created. Next we simply create our `SshServer`, passing it the location of our private RSA key and the corresponding password and start the server. If you need to create your SSH keys, I suggest either looking at `main.py` in this repository, or looking at either [this article](https://phoenixnap.com/kb/generate-ssh-key-windows-10), which explains how to do it on windows, or [this one](https://www.ssh.com/ssh/keygen/) which explains how to do it on Linux.
309+
### Running the SSH Server
310+
311+
Finally we can test all of our code up to this point. First we import our `SshServer` class we just created. Next we simply create our `SshServer`, passing it the location of our private RSA key and the corresponding password and start the server. If you need to create your SSH keys, I suggest either looking at `main.py` in this repository, or looking at either [this article](https://phoenixnap.com/kb/generate-ssh-key-windows-10), which explains how to do it on windows, or [this one](https://www.ssh.com/ssh/keygen/) which explains how to do it on Linux.
277312

278313
```py
279314
from src.ssh_server import SshServer
@@ -283,11 +318,66 @@ if __name__ == '__main__':
283318
server.start()
284319
```
285320

286-
We now run the code using the command `python3 main.py`. We can open up a new Terminal/PowerShell/CMD window and try to connect to our SSH server using the following command: `ssh admin@127.0.0.1 -p 22`. This command will try to connect to an SSH server running on 127.0.0.1:22 as the username `admin`. If you use a different username, change it here. Once you run this command, you should see the banner text you set in our `SshServerInterface` class earlier, as well as a prompt to enter our password. For this example, we can type in `password` and we are given access to an instance of our custom shell! Exciting!
321+
We now run the code using the command `python3 main.py`. We can open up a new Terminal/PowerShell/CMD window and try to connect to our SSH server using the following command: `ssh admin@127.0.0.1 -p 22`. This command will try to connect to an SSH server running on 127.0.0.1:22 as the username `admin`. If you use a different username, change it here. Once you run this command, you should see the banner text you set in our `SshServerInterface` class earlier, as well as a prompt to enter our password. For this example, we can type in `password` and we are given access to an instance of our custom shell! Exciting!
287322

288323
You've noticed there are some issues. Yeah, I know. It's not perfect, but hopefully this will get people started for creating their own custom shells and custom SSH servers. If you know how to fix any of the issues, like how the spacing is all out of whack, please create a pull request so we can fix these issues and provide the correct information to everyone.
289324

290-
### Dockerize the App
325+
## Dockerize the App
326+
291327
Cool, so our SSH server works. Now we want to use this somewhere else. Docker is the answer. Let's learn how we can dockerize our app and get it running everywhere (that has Docker installed)!
292328

329+
### `requirements.txt`
330+
331+
`requirements.txt` is a file created for us by `pip`. It includes all of the packages that are installed within our environment. If you used `venv` to create your environment, this will result is a small file with just the packages we've used. Run the following command to generate your `requirements.txt` file once you're ready to Dockerize your application:
332+
333+
```sh
334+
pip freeze > requirements.txt
335+
```
336+
337+
### Application Modifications
338+
339+
We need to ensure our private key is generated within our Docker container and that we use this file within our container as our private key within our app. This is as simple as changing the path to our private key in `main.py` to `~/.ssh/id_rsa`.
340+
341+
### `Dockerfile`
342+
343+
`Dockerfile` is where you define environment your container will run within. Technically, your `Dockerfile` is used to create an "image", which will then be used to create a "container" which runs your application. You can think of the container as an instance of the image.
344+
345+
When you break down a `Dockerfile`, you typically will see a `FROM` tag at the top, specifying the base image your image will utilize. In our case, we use `ubuntu`. You can also see within the file are `RUN` commands, which do exactly what they say and run a given command within the build stage of your `Dockerfile`. To reiterate, our `Dockerfile` is our method of defining our environment. You can sort of think of this as setting up a new PC and the commands you'd use to install what you need to get your app running. There's a TON of Docker images to base your `Dockerfile` off of, and it can save you a lot of work if you find a base image that does what you need for your specific case.
346+
347+
For our app, we simply use Ubuntu, install updates, Python and pip, copy our files into our container, install our pip requirements, expose our desired ports and finally run our application.
348+
349+
An important note to remember about Docker and writing `Dockerfile` is to keep commands which won't change up top. Generally speaking, installations should be higher in your `Dockerfile` and copying/compiling your application files should be toward the bottom. This allows Docker to cache your images so you don't have to constantly wait for Docker to build and install prerequisites every time you change your source code.
350+
351+
### Building and Running the `Dockerfile`
352+
353+
Now that you've defined the `Dockerfile`, you can build a corresponding image we will eventually use for our container. Run the following command:
354+
355+
```sh
356+
docker build . --tag python_ssh_server
357+
```
358+
359+
The `build` command takes the path to the directory containing your `Dockerfile` and a tag which we use to easily reference our image in our next command:
360+
361+
```sh
362+
docker run --rm -e SSH_PORT=2222 -p 2223:2222 --name my_ssh_app_container python_ssh_server:latest
363+
```
364+
365+
The `run` command allows use to specify the name of our container and the image we wish to instantiate from. I also include `--rm`, which removes the container once execution is completed. We also need to specify the ports we wish to expose from our container using `-p`. The syntax for `-p` is `-p [local_port]:[container_port]`, where `container_port` is the port your application uses, and `local_port` is the port which will be mapped to the container. When `local_port` is set to 2223, we connect to our SSH server using `ssh 0.0.0.0 -p 2223` from our local machine.
366+
367+
Read more about these flags in the Docker documentation if you wish.
368+
369+
### Connecting to your Containerized SSH Server
370+
371+
Now that the Docker container is running, you simply SSH in like you would any other SSH server:
372+
373+
```sh
374+
ssh admin@0.0.0.0 -p 2223
375+
```
376+
377+
Here the server address is `0.0.0.0` (default for Docker containers). We also specify our port using `-p`, where the port matches what we mapped in the container. Once you run this command, you should be connected to your SSH server, running from within a Docker container!
378+
293379
### Conclusion
380+
381+
We did it! We created a custom SSH shell using Python, which can run anywhere Docker is installed. If you have questions, please raise an issue and I'll do my best to answer your question to the best of my ability and within a _"timely"_ (it may take a long time) manner.
382+
383+
Thanks for sticking around and learning with me.

docker/Dockerfile

Whitespace-only changes.

docker/requirements.txt

Whitespace-only changes.

main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import os
12
from src.ssh_server import SshServer
23

4+
35
if __name__ == '__main__':
46
# How to Generate your SSH keys
57
#
@@ -14,8 +16,8 @@
1416
# Next, open cmd as administrator. Enter the command `ssh-keygen` and follow the on screen prompts.
1517
# The location of the key will be displayed. Copy that and paste the location here.
1618
# If you put a password, include it as the second parameter, otherwise don't include it.
17-
server = SshServer('C:/Users/ramon/.ssh/id_rsa')
19+
server = SshServer(os.path.expanduser('~/.ssh/id_rsa'))
1820

1921
# Start the server, you can give it a custom IP address and port, or
2022
# leave it empty to run on 127.0.0.1:22
21-
server.start()
23+
server.start("0.0.0.0", int(os.getenv("SSH_PORT", 22)))

requirements.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
bcrypt==4.2.0
2+
cffi==1.17.1
3+
cryptography==43.0.3
4+
paramiko==3.5.0
5+
pycparser==2.22
6+
PyNaCl==1.5.0

0 commit comments

Comments
 (0)