note

This article was last updated on April 21, 2023, 1 year ago. The content may be out of date.

Background

ttyd is a simple tool for sharing terminal over the web. It can be used in place of ssh clients to manage remote servers as it only requires the presence of a browser with internet connectivity. It can be used with a variety of commands ranging from showing the output of top command to fully interactive bash session.

However, ttyd itself only supports basic auth and auth proxy. In basic auth mode, ttyd authenticates requests from a predefined list of users. In auth proxy mode, ttyd will only authenticate clients with the specific http header. The first mode has the problem that users cannot be updated while ttyd is running. ttyd must be stopped and rerun with another set of users if users are to be updated. The second mode assumes ttyd is behind a reverse proxy and delegates authentication to the reverse proxy. If I’m using a reverse proxy to authenticate the request, then why check the header anyway?

Caddy has provided a feature called forward_auth, i.e. proxies a clone of the request to an authentication gateway, which can decide whether handling should continue, or needs to be sent to a login page. It even provides examples of using it with Authelia and Tailscale. Why not write one myself?

What is Required of the Authentication?

  1. dynamic passwords
  2. session management
  3. minimal code

Dynamic Passwords

There are many ways to generate dynamic passwords, TOTP is one of them, and it has a golang library support. It’s mostly used in 2FA systems.

warning

TOTP are based on a shared a secret. Once the secret is leaked, attackers can generate new valid passwords as well.

To use TOTP, first create a key:

key, err := totp.Generate(totp.GenerateOpts{
	Issuer:      "issuer",
	AccountName: "account",
})
if err != nil {
    panic(err)
}

We can save the key as an image that can be scanned by 2FA apps:

// creating the image
img, err := key.Image(256, 256)
if err != nil {
    panic(err)
}

// open file for writing
file, err := os.Create("totp.png")
if err != nil {
    panic(err)
}

// writing the image
err = png.Encode(file, img)
if err != nil {
    panic(err)
}

// close the file
file.Close()

Or just display the secret as a string:

fmt.Println(key.Secret())

Below is the qr code for the secret J47DK3EVIP5HWOSHS2CPUTEOWLYV5IAJ:

The Qr Code for J47DK3EVIP5HWOSHS2CPUTEOWLYV5IAJ

Session Management

When a client passes authentication, the server will create a session and send cookies to the client. When the client connects again, the server can just authenticate the request by looking at the cookie.

Redis has a built-in expire functionality - using redis to store session keys is very natural.

There is also the need to refresh session tickets when the current session is about to end. It can be done by creating a new session and send new cookies to the client when the ttl (time to live) of the current session is below a specified threshold.

Minimal Code

Use basic auth. golang has builtin support for parsing basic auth and the password will change anyway.

Piecing the Puzzle

With all the requirements worked out, there is still one part missing - caddy integration:

According to the documentation, upon receiving a request, caddy will first forward it to the authentication upstream, if the upstream responds with a 2XX response, the request is passed to the backend, else the response from the authentication upstream is relayed back to the client, typically this is a redirect to the login page. But since we are using basic auth, respond with 401 and the browser will usually prompt for a login.

The full pipeline is this:

graph TD A[Request] --> B{Check Session} B -->|Valid| C(Auth Ok) C --> D[Done] B -->|Invalid| E{Check Login} E -->|Valid| F(Write the Cookie) F --> C E -->|Invalid| G(Unauthorized) G --> D

The function signature is:

func(writer http.ResponseWriter, request *http.Request)

Check Session

We are storing session information in redis and attach the session to the ttyd cookie. rdb is a redis client initialized globally. The code below only checks if the session is valid - it doesn’t create a new session when the current one is below a certain threshold (it’s easy to add though).

func checkSession(request *http.Request) bool {
    cookie, err := request.Cookie("ttyd")
    if err != nil {
        return false
    }

    _, err = rdb.TTL(cookie.Value).Result()
    if err != nil {
    	return false
    }
    return true
}

Check Login

Golang already provides the means to extract basic auth information, we just need to verify it against totp.

func checkLogin(request *http.Request) bool {
	user, password, ok := request.BasicAuth()
	if !ok {
		return false
	}

	if user != "root" {
		return false
	}

	if !totp.Validate(password, "J47DK3EVIP5HWOSHS2CPUTEOWLYV5IAJ") {
		return false
	}

	return true
}

When the login request is authenticated, the server will generate a random string as the session ticket and send it to the client, so that future requests will carry this session information:

func writeCookie(writer http.ResponseWriter) {
    value := randomString()
    
    http.SetCookie(writer, &http.Cookie{
        Name:     "ttyd",
        Value:    value,
        MaxAge:   86400,
        Secure:   true,
        HttpOnly: true,
    })
    
    rdb.Set(value, "", 24*time.Hour)
}

Unauthorized

To let browsers prompt for user and password input, we’ll write some necessary headers:

writer.Header().Set("WWW-Authenticate", "Basic realm=\"ttyd\"")
http.Error(writer, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)

Full Function

The full checkAuth function can be composed like:

func checkAuth(writer http.ResponseWriter, request *http.Request) {
    if checkSession(request) {
        return
    }
    
    if checkLogin(request) {
        writeCookie(writer)
        return
    }
    
    writer.Header().Set("WWW-Authenticate", "Basic realm=\"ttyd\"")
	http.Error(writer, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
    return
}

After writing the function, we’ll need to register it. Using golang stdlib for example:

http.HandleFunc("/auth", checkAuth)

Caddy Integration

Just follow the example on the caddy documentation.

// auth port
forward_auth host:port1 {
    uri /auth
    // no copy_headers needed
}

// ttyd port
reverse_proxy host:port2