前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >创建一个简单的SSH服务器

创建一个简单的SSH服务器

作者头像
drunkdream
发布2023-10-17 08:04:35
4040
发布2023-10-17 08:04:35
举报
文章被收录于专栏:醉梦轩醉梦轩

0x00 前言

为了加深对SSH协议的理解,准备自己实现一个SSH服务端,需要同时支持WindowsLinuxMacOS三大系统。为了尽量提升性能,准备使用协程(asyncio)来开发。

0x01 基于AsyncSSH开发一个最简单的SSH服务端

在调研了几个开源的python SSH库后,最终选择了AsyncSSH。这个库基于asyncio开发,符合我们的要求,同时扩展性也比较好。

下面实现了一个使用固定账号密码登录的SSH服务器,登录成果后会打印一串字符串,并退出:

代码语言:javascript
复制
import asyncio
import asyncssh

async def start_ssh_server():
    def handle_client(process):
        process.stdout.write("Welcome to my SSH server, byebye!\n")
        process.exit(0)

    class MySSHServer(asyncssh.SSHServer):
        def __init__(self):
            self._conn = None

        def password_auth_supported(self):
            return True

        def validate_password(self, username, password):
            return username == "drunkdream" and password == "123456"

        def connection_made(self, conn):
            print("Connection created", conn.get_extra_info("peername")[0])
            self._conn = conn

        def connection_lost(self, exc):
            print("Connection lost", exc)

    await asyncssh.create_server(
        MySSHServer,
        "",
        2222,
        server_host_keys=["skey"],
        process_factory=handle_client,
    )

    await asyncio.sleep(1000)

loop = asyncio.get_event_loop()
loop.run_until_complete(start_ssh_server())

server_host_keys是服务端的私钥文件列表,用于在建立连接时验证服务端的合法性;在第一次连接时客户端会弹出验证指纹的提示,选择yes后会将指纹保存到本地,下次连接时会验证指纹是否匹配,不匹配会报错。

代码语言:javascript
复制
The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established.
RSA key fingerprint is SHA256:nyXXvfYgedKWPRnhl1ss6k+R5cqFleUQu/fDhYYXESI.
Are you sure you want to continue connecting (yes/no)?
代码语言:javascript
复制
ssh drunkdream@127.0.0.1 -p 2222
Password:
Welcome to my SSH server, byebye!
Connection to 127.0.0.1 closed.

这样就实现了一个最简单的SSH服务器了,由此可见,使用AsyncSSH开发SSH服务端是非常方便的。

0x02 支持Shell命令

SSH最常用的功能就是远程终端(shell),下面来实现一个支持执行命令的SSH服务:

代码语言:javascript
复制
async def start_ssh_server():
    import asyncssh

    async def handle_client(process):
        proc = await asyncio.create_subprocess_shell(
            process.command or "bash -i",
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            close_fds=True,
        )
        stdin = proc.stdin
        stdout = proc.stdout
        stderr = proc.stderr

        tasks = [None, None, None]

        while proc.returncode is None:
            if tasks[0] is None:
                tasks[0] = asyncio.ensure_future(process.stdin.read(4096))
            if tasks[1] is None:
                tasks[1] = asyncio.ensure_future(stdout.read(4096))
            if tasks[2] is None:
                tasks[2] = asyncio.ensure_future(stderr.read(4096))

            done_tasks, _ = await asyncio.wait(
                tasks, return_when=asyncio.FIRST_COMPLETED
            )

            for task in done_tasks:
                index = tasks.index(task)
                assert index >= 0
                tasks[index] = None
                buffer = task.result()
                if not buffer:
                    return -1

                if index == 0:
                    stdin.write(buffer)
                elif index == 1:
                    process.stdout.write(buffer.replace(b"\n", b"\r\n"))
                else:
                    process.stderr.write(buffer.replace(b"\n", b"\r\n"))
        return proc.returncode

    class MySSHServer(asyncssh.SSHServer):
        def __init__(self):
            self._conn = None

        def password_auth_supported(self):
            return True

        def validate_password(self, username, password):
            return username == "drunkdream" and password == "123456"

        def connection_made(self, conn):
            print("Connection created", conn.get_extra_info("peername")[0])
            self._conn = conn

        def connection_lost(self, exc):
            print("Connection lost", exc)

    await asyncssh.create_server(
        MySSHServer,
        "",
        2222,
        server_host_keys=["skey"],
        process_factory=lambda process: asyncio.ensure_future(handle_client(process)),
        encoding=None,
        line_editor=False
    )

    await asyncio.sleep(1000)

与前一个版本相比,主要是修改了handle_client实现,变成了一个协程函数,里面创建了子进程,并支持将ssh客户端输入的命令传给子进程,然后将子进程的stdout和stderr转发给ssh客户端。注意到,这里将line_editor参数设置成了False,主要是为了支持实时命令交互。这个参数后面还会详细介绍。

上面的代码在实际使用中发现,对于很快执行完的命令,如:ifconfig等,使用上没什么问题,但是如果输入python命令进入交互式界面,就会卡住没有任务输入。这是因为使用create_subprocess_shell方式创建的子进程不支持pty导致的。

0x03 支持pty

pty(pseudo-tty)是伪终端的意思,也就是虚拟了一个终端出来,让进程可以像正常终端一样进行交互(通常情况下通过管道重定向输入输出的进程都无法支持交互式操作)。交互式终端下缓冲模式是无缓冲(字符模式),也就是stdout每次只要有输出就会打印出来;而非交互式终端是行缓冲模式,stdout必须收到\n换行符才会打印出来。

也就是说,如果终端要支持像python交互式命令这样的场景,必须支持pty。python中可以通过sys.stdout.isatty()来判断当前进程是否支持伪终端。

代码语言:javascript
复制
python -c 'import sys;print(sys.stdout.isatty())'
True

python -c 'import sys;print(sys.stdout.isatty())' > /tmp/1.txt && cat /tmp/1.txt
False

python -c 'import pty; pty.spawn(["python", "-c", "import sys;print(sys.stdout.isatty())"])' > /tmp/1.txt && cat /tmp/1.txt
True

从上面可以看出,经过重定向之后,isatty返回值变成了False;但是使用pty.spawn函数之后,重定向就不会影响isatty的返回值了。这里的秘密就在于pty库实现了一个虚拟的tty,具体实现原理我们后面有时间再来分析。

因此,可以使用以下代码创建一个支持pty的子进程:

代码语言:javascript
复制
import pty

cmdline = list(shlex.split(command or os.environ.get("SHELL", "sh")))
exe = cmdline[0]
if exe[0] != "/":
    for it in os.environ["PATH"].split(":"):
        path = os.path.join(it, exe)
        if os.path.isfile(path):
            exe = path
            break

pid, fd = pty.fork()
if pid == 0:
    # child process
    sys.stdout.flush()
    try:
        os.execve(exe, cmdline, os.environ)
    except Exception as e:
        sys.stderr.write(str(e))
else:
    # parent process
    print(os.read(fd, 4096))

上面的方法只能支持Linux和MacOS系统,Windows 1809以上版本可以使用以下方法:

代码语言:javascript
复制
cmd = (
    "conhost.exe",
    "--headless",
    "--width",
    str(size[0]),
    "--height",
    str(size[1]),
    "--",
    command or "cmd.exe",
)
proc = await asyncio.create_subprocess_exec(
    *cmd,
    stdin=asyncio.subprocess.PIPE,
    stdout=asyncio.subprocess.PIPE,
    stderr=asyncio.subprocess.PIPE,
)

conhost.exe里使用CreatePseudoConsole等相关函数,实现了伪终端。低版本Windows上就需要使用其它方式来支持了,例如:winpty

0x04 行编辑器模式

前面提到,在使用asyncssh.create_server函数创建SSH服务端时,有个line_editor参数设置成了False。这表示关闭了行编辑器模式,也就是说任何输入的字符都会被实时发送给shell进程,一般这种都是shell进程拥有伪终端的情况。

但如果创建的是一个不支持伪终端的shell进程,就必须关闭行编辑器模式,也就是将line_editor置为True。此时,SSH客户端输入的字符会被asyncssh库捕获并进行处理,直到用户按下Enter键的时候,才会将输入一次性发送给shell进程。

具体可以参考文档

0x05 支持端口转发

SSH服务器有个非常有用的功能就是端口转发,包括正向端口转发和反向端口转发。使用方法如下:

正向端口转发:

代码语言:javascript
复制
ssh -L 127.0.0.1:7778:127.0.0.1:7777 root@1.2.3.4

此时,可以将远程机器上的7777端口映射到本地的7778端口。

反向端口转发:

代码语言:javascript
复制
ssh -R 127.0.0.1:7778:127.0.0.1:7777 root@1.2.3.4

此时,可以将本地的7777端口映射到远程机器上的7778端口。

要支持端口转发,只需要MySSHServer类增加connection_requestedserver_requested方法即可。

代码语言:javascript
复制
    async def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
        # 正向端口转发
        return await self._conn.forward_connection(dest_host, dest_port)

    def server_requested(self, listen_host, listen_port):
        # 反向端口转发
        return True

0x06 支持密钥登录

通常我们登录SSH服务器,更多的是使用密钥方式登录。要开启这个特性只需要增加以下两个方法即可:

代码语言:javascript
复制
    def public_key_auth_supported(self):
        return True

    def validate_public_key(self, username, key):
        return True

0x07 总结

使用AsyncSSH库开发SSH服务器还是比较简单的,很多特性都已经封装好了,只要重写一下对应的方法,返回True就可以了。同时,它也提供了高级可定制化的能力,以便实现较为复杂的功能。

完整的SSH服务器代码可以参考:https://github.com/drunkdream/turbo-tunnel/blob/master/turbo_tunnel/ssh.py#L24

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-07-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0x00 前言
  • 0x01 基于AsyncSSH开发一个最简单的SSH服务端
  • 0x02 支持Shell命令
  • 0x03 支持pty
  • 0x04 行编辑器模式
  • 0x05 支持端口转发
  • 0x06 支持密钥登录
  • 0x07 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档