One gotty, many terminals

In a previous post, Julia Evans described a problem with gotty, a Go webserver that exposes a terminal session through a browser. Running gotty top serves the output of top on port 8080; running gotty -w bash lets anyone connect and type commands into a shell. The limitation: each gotty process handles only one session at a time.

For her puzzle game, where people SSH into virtual machines via gotty in the browser, that meant running many gotty processes, each on its own port. Coordination happened through a separate Go proxy server that mapped session IDs to ports, plus a Rails server starting and tracking the gotty processes. Three moving parts, frequent failures. The solution turned out to be much simpler: modify gotty itself to handle multiple simultaneous connections.

A small change to gotty's handlers

The fork (multi-gotty) needed only a small change because gotty exposes just three HTTP handlers: handleAuthToken, handleWS, and a static file handler. The new code adds a routing layer in front of those, dispatching based on the URL's session ID.

The core logic is a single if statement, written as a Go HTTP handler:

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)
	}
}

When a request path ends in /ws, the handler calls app.handleWS with a custom command pulled from a mapping; otherwise it falls through to the standard handler. The path parsing is admittedly rough, the author notes, and may be tidied later.

The command mappings are resolved by a readMapping function that fetches JSON from a backing server (the Rails app) and returns the command to run for a given session ID:

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
}

Why the fork, not a library

The two needed methods, handleWS and handleAuthToken, are private. In Go, that means lowercase names, which cannot be called from outside the package. So the team forked gotty rather than importing it as a library. The complete diff shows that most command line flags, along with TLS and other unneeded features, were deleted along the way.

The design works because Go HTTP handlers all conform to the same signature — func(http.ResponseWriter, *http.Request) — which makes wrapping and modifying existing handlers straightforward. That interface is the real enabler here.

One process instead of three

Replacing the three-component architecture with a single Go program that coordinates everything produced immediate results. The new setup worked right away and remained stable where the old one had been flaky. A URL like http://mysite.com/terminal_session/SOME_ID/ automatically establishes the right SSH connection for that session ID.

The code is on GitHub, along with the full diff of changes to gotty. Known limitations include the rough path parsing noted above and no support yet for changing accepted Origin: headers. Still, the core goal — many different terminal applications served from one gotty instance — is met.