Libations: Tailscale on the Rocks
Table of Contents
Introduction #
I’m a long-time, self-professed connoisseur of cocktails. I’ve always enjoyed making (and drinking!) the classics, but I also like experimenting with new base spirits, techniques, bitters etc.
Over the years, I’ve collected recipes from a variety of sources. Some originated from books (such as Cocktail Codex and Cocktails Made Easy), others from websites (Difford’s Guide), and most importantly those that I’ve either guessed from things I’ve drunk elsewhere (like my favourite bar in Bristol, The Milk Thistle), or modifications to recipes from the referenced sources.
I wanted somewhere to store all these variations: somewhere that I could search easily from my iPhone (which is nearly always close to me when I’m making drinks). I wanted each recipe to fit in its entirety on my iPhone screen without the need to scroll.
Around the time I started thinking about this problem, I also learned about tsnet
and was desperate for an excuse to try it out - and thus Libations was born as the product of two things I love: Tailscale and cocktails!
tswhat? #
Some time ago, Tailscale released a Go library named tsnet
. To quote the website:
tsnet is a library that lets you embed Tailscale inside of a Go program
In this case, the embedded Tailscale works slightly different to how tailscaled
works (by default, anyway…). Rather than using the universal TUN/TAP driver in the Linux kernel, tsnet
instead uses a userspace TCP/IP networking stack, which enables the process embedding it to make direct connections to other devices on your tailnet as if it were “just another machine”. This makes it easy to embed, and drops the requirement for the process to be privileged enough to access /dev/tun
.
One of the things I like about how tsnet
presents applications as devices on the tailnet, is that you can employ ACLs to control who and what on your tailnet can access the application, rather than the device. I’ve solved this problem before by putting applications in their own systemd-nspawn
container and joining those containers to my tailnet. Another nice option is tsnsrv
which essentially acts as a Tailscale-aware proxy for individual applications, but in this case I wanted to bake it into the application - which I would only access over my tailnet.
Getting started with tsnet
couldn’t be easier:
|
|
That will get you set up with a basic Go project, with the tsnet
library available. Create a new main.go
file with the following contents:
|
|
This is about the most minimal example I could contrive. The code creates a simple instance of tsnet.Server
with the hostname tsnet-app
, listens on port 8080
and serves up a simple Hello, World!
style message. On running the application you’ll see the following:
|
|
Clicking the link will open a page in your browser that runs you through Tailscale’s authentication flow, after which you should be able to curl
the page directly from any of your devices (assuming you’re not doing anything complicated with ACLs that might prevent it)!
|
|
The library has a pretty small API surface, all of which is documented on pkg.go.dev.
Libations #
For my cocktail app, I wanted to employ a similar, albeit simplified, set of techniques that I use to build this blog. I love Go’s ability to embed static files that can be served as web assets.
Recipe Schema #
I wanted to represent recipes in a format that could be updated by hand if necessary, and easily parsed into a web frontend. I decided to use JSON for this, representing the recipes as a list:
|
|
This schema is able to capture all the relevant details from the different formats I’ve seen over the years. It would take some time to format my favourites into this schema, but that was always going to be the case.
I was fortunate enough to get access to the recipe collection from a well regarded cocktail bar in the UK. Unfortunately it was given to me in a hard-to-parse PDF, which resulted in many hours of playing with OCR tools and manual data cleaning - but enabled me to bootstrap the app with around 450 high quality recipes. I didn’t include their recipes in the libations repository, but I did include some of my own favourite concoctions in a sample recipe file. My Mezcal Margarita gets pretty good reviews 😉.
Server #
The server implementation needed to fulfil a few requirements:
- Parse a specific recipes file, optionally passed via the command line
- Have an embedded filesystem to contain static assets and templates
- Be able to render HTML templates with the given recipes
- Listen on either a tailnet (via
tsnet
), or locally (for testing convenience) - When listening on the tailnet, listen on HTTPS, redirecting HTTP traffic accordingly
I wanted to keep dependencies to a minimum to make things easier to maintain over time. The tsnet
library pulls in a few indirect dependencies, but everything else Libations uses is in the Go standard library.
The recipes JSON schema is very simple, and is modelled with a couple of Go structs:
|
|
The parseRecipes
function checks whether or not the user passed the path to a specific recipe file, or whether it should default to parsing the sample recipes file. Once it’s determined the right file to parse, and validated its existence, it unmarshals the JSON using the Go standard library.
Users have the option of passing the -local
flag when starting Libations, which bypasses tsnet
completely and starts a local HTTP listener on the specific port. This makes for easier testing when iterating through changes to the web UI and other elements:
|
|
Setting up the tsnet
server and listener is only mildly more complicated – but mostly due to my requirement that all HTTP traffic is redirected to HTTPS, using the LetsEncrypt certificates that Tailscale provides automatically. The redirects are handled by a separate Goroutine in this case:
|
|
With the correct listener selected, I create an http.ServeMux
to handle routing to static assets and rendering templates, and pass that mux to the http.Serve
method from the Go standard library - and that’s it! At the time of writing the Go code totals 235 lines - not bad!
Web Interface #
The web interface was designed primarily for mobile devices, and I’ve not yet done the work to make it excellent for larger-screened devices - though it’s certainly bearable. It’s also read-only at the moment: you can browse all the recipes, and there is a simple full-text search which can narrow the list of recipes down by searching for an ingredient, method, glass type, notes, etc.
As mentioned in an earlier post, I’m a big fan of the Vanilla Framework, which is a “simple, extensible CSS framework” from Canonical, and is used for all of Canonical’s apps and websites. Given that I had some prior experience using it, I decided to use it again here. I started using my tried and tested recipe of Vanilla + Hugo, but later reverted to using simple HTML templates with Go’s html/template
package.
The result is a set of templates, which iterate over the recipe data from the JSON file, and output nicely styled HTML elements:
There is nothing fancy going on here - it’s nearly all stock Vanilla Framework. I do specify some overrides to make the colours a bit less Ubuntu-ish, but that’s it!
One detail I’m pleased with is the dynamic drink icons. The icons indicate the type of glass the particular drink should be served in. This is a simple trick: for each drink the HTML template renders a glass-icon
partial, which reads the glass type specified in the recipe, and renders the appropriate SVG file which is then coloured with CSS.
Packaging for NixOS #
There were two main tasks in this category: creating the Nix package itself, and writing a simple NixOS module that would make it simple for me to run it on my NixOS server.
The project uses a Flake to provide the package, overlay and module. The standard library in Nix has good tooling for Go applications now, meaning the derivation is short:
|
|
I haven’t cut any versioned releases of Libations at the time of writing - I’m using the last modified date of the flake to version the binaries.
The module starts the application with systemd
, and optionally provides it with a recipes file. There are four options defined at the time of writing: services.libations.{enable,recipesFile,tailscaleKeyFile,package}
:
|
|
The tailscaleKeyFile
option enables the service to automatically join a tailnet using an API key, rather than prompting the user to click a link and authorise manually.
These options are translated into a simple systemd
unit:
|
|
The XDG_CONFIG_HOME
variable is set so that tsnet
stores it’s state in /var/lib/libations
, rather than trying to store it in the home directory of the dynamically created user for the systemd
unit.
I use agenix on my NixOS machines to manage encrypted secrets, and for this project I used it to encrypt both the initial Tailscale auth key, and my super-secret recipe collection! The configuration to provide the secrets and configure the server to run libations is available on Github, but looks like so:
|
|
As a result, the application is now available at https://libations
, with a valid LetsEncrypt certificate, on all of my machines! 🎉
Summary #
This was a really fun project. It felt like a fun way to explore tsnet
, and resulted in something that I’ve used a lot over the past year. I don’t have many plans to adjust things in the near future - though I do find myself wanting a nice interface to add new recipes from time to time.
And now for the twist: three weeks ago I gave up drinking alcohol (likely for good), so now I’m on a mission to find some non-alcoholic recipes to make life a little tastier! I suspect this will be hard work - and I’ll certainly miss some of my favourites, but my wife and I have already found some compelling alternatives.
If you’ve got a favourite recipe (alcoholic or not) and you liked the article, then perhaps open a PR and add it to the sample recipes file!