Julia Evans

Day 38: Modifying gotty to serve many different terminal applications at once

In the last post I talked about a problem I was having with gotty.

I’m going to recap the problem, and then we’ll talk about what I did about it! Basically I paired on the problem with Chetan (another Recurser) and we solved it in a really satisfying way and it was way easier than I thought it would be.

Everything from this post is in a github repository here: https://github.com/jvns/multi-gotty/

what’s gotty?

gotty is a Go webserver that lets you put your terminal on a website. So for example if you run gotty top, it’ll start a webserver on port 8080 where it shows the output of top.

Or if you run gotty -w bash, it’ll start bash and start a webserver where anyone can type in commands and run them in your shell.

In my puzzle game thing I have people ssh to some virtual machines I’ve setup. I’ve been using gotty to give them a terminal in the browser.

the problem: a really fragile setup

My problem was that I needed to manage a bunch of different SSH connections (one per puzzle that a person has open), and gotty only supports one session at a time.

So I set something up where I ran a bunch of different gotty processes on different ports, and then stored a mapping of which session mapped to which port, and wrote a small go proxy server that proxied /proxy/SESSION_ID to the right port number on the backend.

There were 3 different pieces in this setup:

  • the gotty processes (potentially lots of them)
  • the other go server that proxied connections to those processes
  • the Rails server, which was responsible for starting the gotty processes on the right ports and telling the go server which ports they were running on

the idea: modify gotty so that it can manage multiple websocket connections

I wanted a much simpler setup where I could just go to http://mysite.com/terminal_session/SOME_ID/ and have the right SSH connection automatically set up.

I’m having some trouble explaining what I want the code to do in English so I’ll just show you some code, because it wasn’t really that much code.

the code: really simple!

I was originally worried that it would be really complicated to modify gotty to handle multiple websocket connections but actually it was very straightforward! gotty had just 3 HTTP handler functions (handleAuthToken, handleWS, and a statics handler), so all we needed to do was call those in a slightly different way.

Here’s what the new HTTP handler we wrote looks like. It basically just has an if statement which calls app.handleWS with a custom command if the path ends in /ws. I think there might be a more idiomatic Go way to do this but I don’t know what it is yet. You can see it in context here

func (app *App) handleRequest(w http.ResponseWriter, r *http.Request) {
	staticHandler := http.FileServer(
		&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, Prefix: "static"},
	)
	path := r.URL.Path
	parts := strings.Split(path, "/")
	// TODO: this panics if the path doesn't have enough stuff in it
	// TODO: actually match on /proxy and don't do this strings.Split thing
	prefix := strings.Join(parts[:3], "/")
	if strings.HasSuffix(path, "/auth_token.js") {
		app.handleAuthToken(w, r)
	} else if strings.HasSuffix(path, "/ws") {
		id := parts[2]
		mapping := app.readMapping()
		if command, ok := mapping[id]; ok {
			app.handleWS(command, w, r)
		}
	} else {
		http.StripPrefix(prefix, staticHandler).ServeHTTP(w, r)
	}
}

The parsing code for the path here is still really bad but I’ll fix it at some point.

The only real other code we had to write was this readMapping function which maps an ID from the URL to a command to run. This is just making an HTTP request to a server (my Rails app) and decoding some JSON:

func (app *App) readMapping() map[string][]string {
	resp, err := http.Get(app.commandServer)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	var mapping map[string][]string
	json.Unmarshal(body, &mapping)
	return mapping
}

http.ResponseWriter and http.Request are great

The real star here is the Go http interfaces. Because all Go HTTP handlers take a http.ResponseWriter and a http.Request, it’s super easy to wrap other HTTP request handlers and make them behave slightly differently.

I’d forgotten how it worked but it was pretty easy to pick up again.

I couldn’t use gotty as a library

We forked gotty instead of using it as a library because the functions we needed (handleWS and handleAuthToken) were private, you can tell because they’re lowercase and in Go the way you know if a function is private or public is based on whether it starts with an uppercase or lowercase letter.

pairing is magic

I paired on this with Chetan and it was SO MUCH easier to do with someone else than on my own. I really thought this would be super hard and it wasn’t – it was really helpful to talk it through with another person and we got the main functionality working in just 1 hour!

the new code works WAY BETTER than my old setup

Having everything coordinated in 1 simple Go program instead of having 3 different moving pieces to coordinate works SO MUCH BETTER. I tried it out and basically just worked immediately and reliably instead of constantly being flaky and failing like my old setup.

here’s the code

I put the code on Github here: https://github.com/jvns/multi-gotty/. It has some problems (like the bad path parsing code I mentioned, and it doesn’t let you change the accepted Origin: headers yet), but it does do what I wanted to do!

Here’s the complete diff of all the code we changed / deleted in gotty to make this work. We deleted most of gotty’s command line flags and support for a bunch of things like TLS because I didn’t need them and I didn’t think anyone else would want to use it.

Day 37: A new laptop and a little Vue Day 39: Customizing gotty's terminal