Web Terminal Project Writeup

2026/03/06

Intro

Webterm is a microservice-style application that provides a web interface to access Linux containers hosted in a Kubernetes cluster.

Technologies involved are Go, Kubernetes, ArgoCD, Docker, Helm, Node.js, and JavaScript.

Technical Overview

Source Code

The source for Webterm is available here.

Microservices Overview

General Connection Flow

The backend needs to provide each user with a pseudoterminal backend.

Here’s a rough overview of this process:

What Are Pseudoterminals For?

We’re making a web terminal, but unfortunately the browser cannot run Linux or host a real shell process by itself.

The browser can only render a terminal-like interface and send user input over the network. The actual shell has to run somewhere else, in this case inside a container in the cluster.

This process is enabled by pseudoterminals.

Basically, a pseudoterminal is an OS primitive that attaches to a running process. They are important, as all modern terminal emulators (even local) use pseudoterminals as an abstraction. It can be thought of as pseudoterminals “exchanging” keyboard input for terminal process output.

For a web-based terminal, instead of handling input and output locally, the browser sends user input over the network to a backend container running a pseudoterminal, and the resulting output is streamed back to the browser.

Here’s a quick rundown of how Webterm uses pseudoterminals:

See containerPseudoTerminal.js for the actual pseudoterminal server running in terminal client containers.

Filtering a Kubernetes Watch to Manage Pods

Kubernetes exposes a watch mechanism, which is basically a way to subscribe to changes in cluster objects without constantly polling the API.

pseudo-terminal-manager uses this to notice when a terminal pod changes state, especially when a pod is being recreated or when a newly scaled pod becomes ready.

Here’s some context for the code below:

filter.go

// the 'brain' of the watch filter system
for {
    select {
    case paramToAppend := <-fil.paramStream:
        fil.params = append(fil.params, &paramToAppend)
    case indexToRemove := <-fil.remIndexChan:
        fil.params = remove(fil.params, indexToRemove)
    case event := <-fil.inChan:
        for _, fp := range fil.params {
            if fp.pass(event, fil.done) {
                fp.outChan <- event
            }
        }
    case <-fil.done:
        return
    default:
        if len(fil.params) == 0 {
            close(fil.done)
            runningFilter = nil
        }
    }
}

apiEndpoints.go

// waitPatternPendingRunning is a consumer of an isolated watch event stream that is produced by
// filter.go. waitPatternPendingRunning helps us connect to pods as soon as they're available, and
// avoids a race condition where separate users can connect to the same pod at once.
func waitPatternPendingRunning(fp *filterParam, wg *sync.WaitGroup) {

	var lastPhase string
	for {
		select {
		case event := <-fp.outChan:
			pod, _ := event.Object.(*v1.Pod)

			currentPhase := string(pod.Status.Phase)

			if lastPhase == "Pending" && currentPhase == "Running" {
				fmt.Println("pattern found") //t
				runningFilter.remIndexChan <- runningFilter.getFpIndex(fp)
				wg.Done()
				return
			}
			lastPhase = currentPhase
		}
	}
}

This code works well enough for this project, but in the future I will probably use Kubebuilder for interacting with the Kubernetes API.

Closing

webterm combines a few different ideas into one project: browser-side terminal rendering, pseudoterminals, container networking, and direct use of the Kubernetes API.

This writeup skips many implementation details, like the exact Helm setup, the exact UI-to-terminal connection logic, the GitHub Actions pipeline, the per-pod NodePort Services, and the removal system for stale terminal pods. But the main idea is still simple; the browser asks for a terminal, the manager finds or creates one, and the frontend then talks to a shell running inside a container.

Future plans


Demo Video