Merge pull request #26616 from lujun9972/add-MjAyMjA3MjggQSB0b3kgcmVtb3RlIGxvZ2luIHNlcnZlci5tZAo=

自动选题[tech]: 20220728 A toy remote login server
This commit is contained in:
Xingyu.Wang 2022-07-29 07:32:50 +08:00 committed by GitHub
commit 3c84da6c76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -0,0 +1,444 @@
[#]: subject: "A toy remote login server"
[#]: via: "https://jvns.ca/blog/2022/07/28/toy-remote-login-server/"
[#]: author: "Julia Evans https://jvns.ca/"
[#]: collector: "lujun9972"
[#]: translator: " "
[#]: reviewer: " "
[#]: publisher: " "
[#]: url: " "
A toy remote login server
======
Hello! The other day we talked about [what happened when you press a key in your terminal][1].
As a followup, I thought it might be fun to implement a program thats like a tiny ssh server, but without the security. You can find it [on github here][2], and Ill explain how it works in this blog post.
### the goal: “ssh” to a remote computer
Our goal is to be able to login to a remote computer and run commands, like you do with SSH or telnet.
The biggest difference between this program and SSH is that theres literally no security (not even a password) anyone who can make a TCP connection to the server can get a shell and run commands.
Obviously this is not a useful program in real life, but our goal is to learn a little more about how terminals works, not to write a useful program.
(I will run a version of it on the public internet for the next week though, you can see how to connect to it at the end of this blog post)
### lets start with the server!
Were also going to write a client, but the server is the interesting part, so lets start there. Were going to write a server that listens on a TCP port (I picked 7777) and creates remote terminals for any client that connects to it to use.
When the server receives a new connection it needs to:
1. create a pseudoterminal for the client to use
2. start a `bash` shell process for the client to use
3. connect `bash` to the pseudoterminal
4. continuously copy information back and forth between the TCP connection and the pseudoterminal
I just said the word “pseudoterminal” a lot, so lets talk about what that means.
### whats a pseudoterminal?
Okay, what the heck is a pseudoterminal?
A pseudoterminal is a lot like a bidirectional pipe or a socket you have two ends, and they can both send and receive information. You can read more about the information being sent and received in [what happens if you press a key in your terminal][1]
Basically the idea is that on one end, we have a TCP connection, and on the other end, we have a `bash` shell. So we need to hook one part of the pseudoterminal up to the TCP connection and the other end to bash.
The two parts of the pseudoterminal are called:
* the “pseudoterminal master”. This is the end were going to hook up to the TCP connection.
* the “slave pseudoterminal device”. Were going to set our bash shells `stdout`, `stderr`, and `stdin` to this.
Once theyre conected, we can communicate with `bash` over our TCP connection and well have a remote shell!
### why do we need this “pseudoterminal” thing anyway?
You might be wondering Julia, if a pseudoterminal is kind of like a socket, why cant we just set our bash shells `stdout` / `stderr` / `stdin` to the TCP socket?
And you can! We could write a TCP connection handler like this that does exactly that, its not a lot of code ([server-notty.go][3]).
```
func handle(conn net.Conn) {
tty, _ := conn.(*net.TCPConn).File()
// start bash with tcp connection as stdin/stdout/stderr
cmd := exec.Command("bash")
cmd.Stdin = tty
cmd.Stdout = tty
cmd.Stderr = tty
cmd.Start()
}
```
It even kind of works if we connect to it with `nc localhost 7778`, we can run commands and look at their output.
But there are a few problems. Im not going to list all of them, just two.
**problem 1: Ctrl + C doesnt work**
The way Ctrl + C works in a remote login session is
* you press ctrl + c
* That gets translated to `0x03` and sent through the TCP connection
* The terminal receives it
* the Linux kernel on the other end notes “hey, that was a Ctrl + C!”
* Linux sends a `SIGINT` to the appropriate process (more on what the “appropriate process” is exactly later)
If the “terminal” is just a TCP connection, this doesnt work, because when you send `0x04` to a TCP connection, Linux wont magically send `SIGINT` to any process.
**problem 2: `top` doesnt work**
When I try to run `top` in this shell, I get the error message `top: failed tty get`. If we strace it, we see this system call:
```
ioctl(2, TCGETS, 0x7ffec4e68d60) = -1 ENOTTY (Inappropriate ioctl for device)
```
So `top` is running an `ioctl` on its output file descriptor (2) to get some information about the terminal. But Linux is like “hey, this isnt a terminal!” and returns an error.
There are a bunch of other things that go wrong, but hopefully at this point youre convinced that we actually need to set bashs stdout/stderr to be a terminal, not some other thing like a socket.
So lets start looking at the server code and see what creating a pseudoterminal actually looks like.
### step 1: create a pseudoterminal
Heres some Go code to create a pseudoterminal on Linux. This is copied from [github.com/creack/pty][4], but I removed some of the error handling to make the logic a bit easier to follow:
```
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 English, what were doing is:
* open `/dev/ptmx` to get the “pseudoterminal master” Again, thats the part were going to hook up to the TCP connection
* get the filename of the “slave pseudoterminal device”, which is going to be `/dev/pts/13` or something.
* “unlock” the pseudoterminal so that we can use it. I have no idea what the point of this is (why is it locked to begin with?) but you have to do it for some reason
* open `/dev/pts/13` (or whatever number we got from `ptsname`) to get the “slave pseudoterminal device”
What do those `ptsname` and `unlockpt` functions do? They just make some `ioctl` system calls to the Linux kernel. All of the communication with the Linux kernel about terminals seems to be through various `ioctl` system calls.
Heres the code, its pretty short: (again, I just copied it from [creack/pty][5])
```
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: hook the pseudoterminal up to `bash`
The next thing we have to do is connect the pseudoterminal to `bash`. Luckily, thats really easy heres the Go code for it! We just need to start a new process and set the stdin, stdout, and stderr to `tty`.
```
cmd := exec.Command("bash")
cmd.Stdin = tty
cmd.Stdout = tty
cmd.Stderr = tty
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
```
Easy! Though why do we need this `Setsid: true` thing, you might ask? Well, I tried commenting out that code to see what went wrong. It turns out that what goes wrong is Ctrl + C doesnt work anymore!
`Setsid: true` creates a new **session** for the new bash process. But why does that make `Ctrl + C` work? How does Linux know which process to send `SIGINT` to when you press `Ctrl + C`, and what does that have to do with sessions?
### how does Linux know which process to send Ctrl + C to?
I found this pretty confusing, so I reached for my favourite book for learning about this kind of thing: [the linux programming interface][6], specifically chapter 34 on process groups and sessions.
That chapter contains a few key facts: (#3, #4, and #5 are direct quotes from the book)
1. Every process has a **session id** and a **process group id** (which may or may not be the same as its PID)
2. A session is made up of multiple process groups
3. All of the processes in a session share a single controlling terminal.
4. A terminal may be the controlling terminal of at most one session.
5. At any point in time, one of the process groups in a session is the **foreground process group** for the terminal, and the others are background process groups.
6. When you press `Ctrl+C` in a terminal, SIGINT gets sent to all the processes in the foreground process group
Whats a process group? Well, my understanding is that:
* processes in the same pipe `x | y | z` are in the same process group
* processes you start on the same shell line (`x && y && z`) are in the same process group
* child processes are by default in the same process group, unless you explicitly decide otherwise
I didnt know most of this (I had no idea processes had a session ID!) so this was kind of a lot to absorb. I tried to draw a sketchy ASCII art diagram of the situation
```
(maybe) terminal --- session --- process group --- process
| |- process
| |- process
|- process group
|
|- process group
```
So when we press Ctrl+C in a terminal, heres what I think happens:
* `\x04` gets written to the “pseudotermimal master” of a terminal
* Linux finds the **session** for that terminal (if it exists)
* Linux find the **foreground process group** for that session
* Linux sends `SIGINT`
If we dont create a new session for our new bash process, our new pseudoterminal actually wont have **any** session associated with it, so nothing happens when we press `Ctrl+C`. But if we do create a new session, then the new pseudoterminal will have the new session associated with it.
### how to get a list of all your sessions
As a quick aside, if you want to get a list of all the sessions on your Linux machine, grouped by session, you can run:
```
$ ps -eo user,pid,pgid,sess,cmd | sort -k3
```
This includes the PID, process gruoup ID, and session ID. As an example of the output, here are the two processes in the pipeline:
```
bork 58080 58080 57922 ps -eo user,pid,pgid,sess,cmd
bork 58081 58080 57922 sort -k3
```
You can see that they share the same process group ID and session ID, but of course they have different PIDs.
That was kind of a lot but thats all were going to say about sessions and process groups in this post. Lets keep going!
### step 3: set the window size
We need to tell the terminal how big to be!
Again, I just copied this from `creack/pty`. I decided to hardcode the size to 80x24.
```
Setsize(tty, &Winsize{
Cols: 80,
Rows: 24,
})
```
Like with getting the terminals pts filename and unlocking it, setting the size is just one `ioctl` system call:
```
func Setsize(t *os.File, ws *Winsize) {
ioctl(t.Fd(), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(ws)))
}
```
Pretty simple! We could do something smarter and get the real window size, but Im too lazy.
### step 4: copy information between the TCP connection and the pseudoterminal
As a reminder, our rough steps to set up this remote login server were:
1. create a pseudoterminal for the client to use
2. start a `bash` shell process
3. connect `bash` to the pseudoterminal
4. continuously copy information back and forth between the TCP connection and the pseudoterminal
Weve done 1, 2, and 3, now we just need to ferry information between the TCP connection and the pseudoterminal.
There are two `io.Copy` calls, one to copy the input _from_ the tcp connection, and one to copy the output _to_ the TCP connection. Heres what the code looks like:
```
go func() {
io.Copy(pty, conn)
}()
io.Copy(conn, pty)
```
The first one is in a goroutine just so they can both run in parallel.
Pretty simple!
### step 5: exit when were done
I also added a little bit of code to close the TCP connection when the command exits
```
go func() {
cmd.Wait()
conn.Close()
}()
```
And thats it for the server! You can see all of the Go code here: [server.go][2].
### next: write a client
Next, we have to write a client. This is a lot than the server because we dont need to do quite as much terminal setup. there are just 3 steps:
1. Put the terminal into raw mode
2. copy stdin/stdout to the TCP connection
3. reset the terminal
### client step 1: put the terminal into “raw” mode
We need to put the client terminal into “raw” mode so that every time you press a key, it gets sent to the TCP connection immediately. If we dont do this, everything will only get sent when you press enter.
“Raw mode” isnt actually a single thing, its a bunch of flags that you want to turn off. Theres a good tutorial explaining all the flags we have to turn off called [Entering raw mode][7].
Like everything else with terminals, this requires `ioctl` system calls. In this case we get the terminals current settings, modify them, and save the old settings so that we can restore them later.
I figured out how to do this in Go by going to <https://grep.app> and typing in `syscall.TCSETS` to find some other Go code that was doing the same thing.
```
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
}
```
### client step 2: copy stdin/stdout to the TCP connection
This is exactly like what we did with the server. Its very little code:
```
go func() {
io.Copy(conn, os.Stdin)
}()
io.Copy(os.Stdout, conn)
```
### client step 3: restore the terminals state
We can put the terminal back into the mode it started in like this (another `ioctl`!):
```
func Restore(fd uintptr, oldState syscall.Termios) {
ioctl(fd, syscall.TCSETS, uintptr(unsafe.Pointer(&oldState)))
}
```
### we did it!
We have written a tiny remote login server that lets anyone log in! Hooray!
Obviously this has zero security so Im not going to talk about that aspect.
### its running on the public internet! you can try it out!
For the next week or so Im going to run a demo of this on the internet at `tetris.jvns.ca`. It runs tetris instead of a shell because I wanted to avoid abuse, but if you want to try it with a shell you can run it on your own computer :).
If you want to try it out, you can use `netcat` as a client instead of the custom Go client program we wrote, because copying information to/from a TCP connection is what netcat does. Heres how:
```
stty raw -echo && nc tetris.jvns.ca 7777 && stty sane
```
This will let you play a terminal tetris game called `tint`.
You can also use the [client.go program][8] and run `go run client.go tetris.jvns.ca 7777`.
### this is not a good protocol
This protocol where we just copy bytes from the TCP connection to the terminal and nothing else is not good because it doesnt allow us to send over information information like the terminal or the actual window size of the terminal.
I thought about implementing telnets protocol so that we could use telnet as a client, but I didnt feel like figuring out how telnet works so I didnt. (the server 30% works with telnet as is, but a lot of things are broken, I dont quite know why, and I didnt feel like figuring it out)
### itll mess up your terminal a bit
As a warning: using this server to play tetris will probably mess up your terminal a bit because it sets the window size to 80x24. To fix that I just closed the terminal tab after running that command.
If we wanted to fix this for real, wed need to restore the window size after were done, but then wed need a slightly more real protocol than “just blindly copy bytes back and forth with TCP” and I didnt feel like doing that.
Also it sometimes takes a second to disconnect after the program exits for some reason, Im not sure why that is.
### other tiny projects
Thats all! There are a couple of other similar toy implementations of programs Ive written here:
* [toy tls 1.3 implementation][9]
* [toy dns resolver][10]
--------------------------------------------------------------------------------
via: https://jvns.ca/blog/2022/07/28/toy-remote-login-server/
作者:[Julia Evans][a]
选题:[lujun9972][b]
译者:[译者ID](https://github.com/译者ID)
校对:[校对者ID](https://github.com/校对者ID)
本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出
[a]: https://jvns.ca/
[b]: https://github.com/lujun9972
[1]: https://jvns.ca/blog/2022/07/20/pseudoterminals/
[2]: https://github.com/jvns/tiny-remote-login/blob/main/server.go
[3]: https://github.com/jvns/tiny-remote-login/blob/main/server-notty.go
[4]: https://github.com/creack/pty/blob/7de28cee0d53510e719c1aeb1850af0fa647c343/pty_linux.go
[5]: https://github.com/creack/pty/blob/7de28cee0d53510e719c1aeb1850af0fa647c343/pty_linux.go#L41-L54
[6]: https://man7.org/tlpi/
[7]: https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html
[8]: https://github.com/jvns/tiny-remote-login/blob/main/client.go
[9]: https://jvns.ca/blog/2022/03/23/a-toy-version-of-tls/
[10]: https://jvns.ca/blog/2022/02/01/a-dns-resolver-in-80-lines-of-go/