Building GuppyFLO - Secure Remote Access For All Your Klipper Printers

April 29, 20245 minutes

Building a dynamic reverse proxy with secure remote access for Klipper printers and camera services.

Overview

The software stack for a typical Klipper setup involves a few services. Klipper - the 3d-pinter firmware, Moonraker - the web API layer, a client (e.g. Guppy Screen, Mainsail, Fluidd), and some camera service for monitoring your prints.

Klipper runs local and exposes its interface using a Unix Domain Socket. In order to expose this local interface over the network, Moonraker connects to it and relies the communication through a defined HTTP(S)/Websocket protocol. Now any HTTP server can be used to host a WebUI for controlling the printer, e.g. Nginx, lighttpd, apache, python -m http.server. Since camera services typically run their own servers, these streams are already available over the network. With these couple services, users gain a modern and power way to control their printers locally.

Problem Statement

Imagine you want the same experience for more than one printer. Most of the above steps would be repeated. For seasoned IT professionals, you might centralize the WebUI hosting portion, but then realize Fluidd/Mainsail do not support non-root prefix. Currently you can’t simply host Fluidd/Mainsail for a printer at /MyPrinter1 and another on /MyOtherPrinter, and expect it to route to the correct printer. How about figuring out all those stream paths for all these camera services? Do you need remote access (secure, bandwidth limited) for your printer? What about remote access for those camera streams?

GuppyFLO

Take a look a demo with GuppyFLO proxying a printer and a webcam stream to Mobileraker over tailscale.

guppyflo demo

Requirements

Most of the above problems are solved, from self hosted VPNs to open source/commercial tunneling solutions. GuppyFLO is my attempt at doing the same thing, offering an open source solutions that simplifies Klipper printer management. When planning for GuppyFLO, the following are its hard requirements:

  1. Single dashboard to manage all printers.
  2. Securee remote access. Don’t roll your own authenticaiton, use battle tested authentication flow like OAuth, Public/Private Key.
  3. Simplify camera service setup.
  4. Each printer gets its own unique URL to Mainsail/Fluidd.

Solution

If you call out reverse proxy, and you’re correct. GuppyFLO is a reverse proxy that dynamically configures routes for Klipper/Moonraker and camera services like ustreamer/mjpegstreamer. It slaps a CRUD API and reactjs UI on top for printers/cameras. It hosts forked versions of Fluidd and Mainsail which add support for prefix based routing. It auto discovers camera stream paths for ustreamer/mpegstreamer/go2rtc using their APIs. It then integrates with tailscale (public/private key)/ngrok (OAuth) to secure remote access. All of that in a simple golang service and a reactjs app (skipping the detail of the reactjs app, there are many contents and tutorial covering this reactjs).

Reverse Proxy

Building a reverse proxy in go is surprisingly simple. GuppyFLO mainly uses the following function, reverseProxyHandler, to setup reverse proxy routes:

reverse proxy
func reverseProxyHandler(p *httputil.ReverseProxy, url *url.URL) func(http.ResponseWriter, *http.Request) {
  return func(res http.ResponseWriter, req *http.Request) {
    req.Header.Add("X-Scheme", "http")
    req.Header.Set("X-Real-IP", "127.0.0.1")
    req.Host = url.Host
    req.Header.Add("Origin", "guppytunnel-client.local")
    p.ServeHTTP(res, req)
  }
}

The proxying is complete after calling this function on each of Moonraker and camera services paths:

moonraker paths
  // note the call to reverseProxyHandler
  moonrakerGzHandler := gziphandler.GzipHandler(
    http.StripPrefix(prefix, http.HandlerFunc(
      reverseProxyHandler(moonrakerProxy, moonrakerRemote))))

  printerMux.Handle(prefix+"/websocket", moonrakerGzHandler)
  printerMux.Handle(prefix+"/printer/", moonrakerGzHandler)
  printerMux.Handle(prefix+"/api/", moonrakerGzHandler)
  printerMux.Handle(prefix+"/access/", moonrakerGzHandler)
  printerMux.Handle(prefix+"/machine/", moonrakerGzHandler)
  printerMux.Handle(prefix+"/server/", moonrakerGzHandler)

Since each camera service is unique, their paths are also unique. To discover these paths, GuppyFLO queries their known API endpoints at commonly configures ports.

camera discovery
// go2rtc
GET :1984/api/streams

// ustreamer
GET :8080-:8083/state
camera endpoints
// go2rtc
GET :1984/stream.html?src=<cam-name>

// ustreamer
GET :8080-:8083/?action=stream

After discoverying the endpoints, the same reverseProxyHandler function is called.

proxy cameras
  cameraUrl, err2 := url.Parse(fmt.Sprintf("http://%s:%d",
                               cam.CameraIp,
                               cam.CameraPort))
  ...
  cameraPrefix := fmt.Sprintf("%s/%s/", printerCameraPrefix, cameraId)
  cameraProxy := httputil.NewSingleHostReverseProxy(cameraUrl)
  ...
  camerasMux.Handle(cameraPrefix, gziphandler.GzipHandler(
    http.StripPrefix(cameraPrefix,
    http.HandlerFunc(reverseProxyHandler(cameraProxy, cameraUrl)))))

Hosting Fluidd/Mainsail

GuppyFLO also hosts two forked version of Fluidd and Mainsail. go also makes this as simple as two go routines serving a http.FileServer handlder pointing to UI assets.

fluidd/mainsail
  fluiddMux := http.NewServeMux()
  fluiddMux.Handle("/", gziphandler.GzipHandler(http.FileServer(http.Dir("fluidd"))))

  go func() {
    log.Fatal(http.ListenAndServe(":9871", fluiddMux))
  }()

  mainsailMux := http.NewServeMux()
  mainsailMux.Handle("/", gziphandler.GzipHandler(http.FileServer(http.Dir("mainsail"))))

  go func() {
   log.Fatal(http.ListenAndServe(":9872", mainsailMux))
}()

Tailscale/Ngrok Integration

Both of these services provide native golang libraries to integrate with their service. The libraries also implemtns golang net.Listener which makes integration as simple as creating the respective server and serving them with http.Server. In term of security, tailscale uses public/private key for their wireguard network and hence connection is end-to-end encrypted. For ngrok, it’s a tunneling solution where traffic traverses ngrok servers. Even though ngrok endpoint is HTTPS, printer/camera traffic going over ngrok service is unencrypted and ngrok sees them. GuppyFLO enforces OAuth when using ngrok. This feature offsets the first handshake to ngrok and your OAuth provider (don’t roll your own authentication, use battle tested authentication flow). GuppyFLO will only start answer remote requests once the OAuth is successfull.

tailscale/ngrok integration
  tsServer := new(tsnet.Server)
  tsServer.Hostname = "guppyflo"
  ...
  tsListener, tserr := tsServer.Listen("tcp", ":80")
  ...
  go func() {
    http.Serve(tsListener, guppyMux)
  }()
  ...

  ln, err := ngrok.Listen(ctx,
    config.HTTPEndpoint(oauths...,
    ),
    ngrok.WithAuthtoken(*gtconfig.NgrokAuthToken),
  )
  ...
  http.Serve(ln, guppyMux)

GuppyFLO CRUD API

This is the piece that enables WebUI/HTTP client to get, add, remove, and modify managed printers and cameras in GuppyFLO. It’s a simple API that accepts JSON requests and returns JSON response. Detail implements for printers and cameras endpoints starts here.

guppyflo endpoints
GET    /api/v1/printers
POST   /api/v1/printers
PUT    /api/v1/printers
DELETE /api/v1/printers

GET    /api/v1/cameras

In combination of a reverse proxy, tailscale/ngrok, camera service discovery, and prefix base Mainsail/Fluidd, GuppyFLO is my take on multi-printer management. For completeness, the whole server component of GuppyFLO is a single main.go (pinned to tag 0.0.10).