https://jnsgr.uk/2024/08/tailscale-on-the-rocks
|Skip to main content
Jon Seager
[ ]
*
* Blog
* CV
* Meta
*
*
* Blog
* CV
* Meta
*
*
1. /
2. Blog/
3. Libations: Tailscale on the Rocks/
Libations: Tailscale on the Rocks
21 August 2024*2567 words*13 mins
A dimly lit, prohibition style cocktail bar with chesterfield
sofasand a jazz band performing in front of a neon sign in the
background.Prominent orange cocktail in the foreground.
Table of Contents
* Introduction
* tswhat?
* Libations
+ Recipe Schema
+ Server
+ Web Interface
* Packaging for NixOS
* Summary
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:
1 mkdir tsnet-app; cd tsnet-app
2 go mod init tsnet-app
3 go get tailscale.com/tsnet
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:
1 package main
2
3 import (
4 "fmt"
5 "log"
6 "net/http"
7
8 "tailscale.com/tsnet"
9 )
10
11 func main() {
12 // Create a new tsnet server instance
13 s := tsnet.Server{Hostname: "tsnet-test"}
14 defer s.Close()
15
16 // Have the tsnet server listen on :8080
17 ln, err := s.Listen("tcp", ":8080")
18 if err != nil {
19 log.Fatal(err)
20 }
21 defer ln.Close()
22
23 // Define a very simple handler with a simple Hello, World style message
24 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 fmt.Fprintf(w, "
Hello from %s, tailnet!
\n", s.Hostname)
26 })
27
28 // Start an HTTP server on the tsnet listener
29 err = http.Serve(ln, handler)
30 if err != nil {
31 log.Fatal(err)
32 }
33 }
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:
1 go run .
2 2024/08/13 15:03:37 tsnet running state path /home/jon/.config/tsnet-tsnet-app/tailscaled.state
3 2024/08/13 15:03:37 tsnet starting with hostname "tsnet-test", varRoot "/home/jon/.config/tsnet-tsnet-app"
4 2024/08/13 15:03:38 LocalBackend state is NeedsLogin; running StartLoginInteractive...
5 2024/08/13 15:03:43 To start this tsnet server, restart with TS_AUTHKEY set, or go to: https://login.tailscale.com/a/deadbeeffeebdaed
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)!
1 tailscale status
2 # ...
3 100.93.165.28 kara jnsgruk@ linux -
4 100.106.82.10 tsnet-test jnsgruk@ linux -
5 # ...
6
7 curl http://tsnet-test:8080
8 Hello from tsnet-test, tailnet!
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:
1 [
2 {
3 "id": 10,
4 "name": "New York Sour",
5 "base": ["Bourbon"],
6 "glass": ["12oz Lowball"],
7 "method": ["Dry Shake", "Shake"],
8 "ice": ["Cubed"],
9 "ingredients": [
10 { "name": "Lemon Juice", "measure": "20", "unit": "ml" },
11 { "name": "Sugar", "measure": "20", "unit": "ml" },
12 { "name": "Red Wine", "measure": "10", "unit": "ml" },
13 { "name": "Bourbon", "measure": "40", "unit": "ml" },
14 { "name": "Egg White", "measure": "20", "unit": "ml" }
15 ],
16 "garnish": ["Lemon Sail"],
17 "notes": "Use claret or malbec"
18 }
19 ]
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:
1 // Ingredient represents the name and quantity of a given ingredient in a recipe.
2 type Ingredient struct {
3 Name string
4 Measure string
5 Unit string
6 }
7
8 // Drink represents all of the details for a given drink.
9 type Drink struct {
10 ID int
11 Name string
12 Base []string
13 Glass []string
14 Method []string
15 Ice []string
16 Ingredients []Ingredient
17 Garnish []string
18 Notes string
19 }
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:
1 // ...
2 addr := flag.String("addr", ":8080", "the address to listen on in the case of a local listener")
3 local := flag.Bool("local", false, "start on local addr; don't attach to a tailnet")
4
5 // ...
6 var listener *net.Listener
7 if *local {
8 listener, err = localListener(*addr)
9 } else {
10 listener, err = tailscaleListener(*hostname, *tsnetLogs)
11 }
12 if err != nil {
13 slog.Error("failed to create listener", "error", err.Error())
14 os.Exit(1)
15 }
16 //...
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:
1 // Start a standard HTTP server in the background to redirect HTTP -> HTTPS.
2 go func() {
3 httpLn, err := tsnetServer.Listen("tcp", ":80")
4 if err != nil {
5 slog.Error("unable to start HTTP listener, redirects from http->https will not work")
6 return
7 }
8
9 slog.Info(fmt.Sprintf("started HTTP listener with tsnet at %s:80", hostname))
10
11 err = http.Serve(httpLn, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12 newURL := fmt.Sprintf("https://%s%s", r.Host, r.RequestURI)
13 http.Redirect(w, r, newURL, http.StatusMovedPermanently)
14 }))
15 if err != nil {
16 slog.Error("unable to start http server, redirects from http->https will not work")
17 }
18 }()
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:
screenshot of the libations app displaying the recipe for a mezcal
margarita
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:
1 {
2 buildGo122Module,
3 lastModifiedDate,
4 lib,
5 ...
6 }:
7
8 let
9 version = builtins.substring 0 8 lastModifiedDate;
10 in
11 buildGo122Module {
12 pname = "libations";
13 inherit version;
14 src = lib.cleanSource ../.;
15 vendorHash = "sha256-AWvaHyJL7Cm+zCY/vTuTAsgLbVy6WUNfmaGbyQOzMMQ=";
16 }
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}:
1 options = {
2 services.libations = {
3 enable = mkEnableOption "Enables the libations service";
4
5 recipesFile = mkOption {
6 type = nullOr path;
7 default = null;
8 example = "/var/lib/libations/recipes.json";
9 description = ''
10 A file containing drinks recipes per the Libations file format.
11 See https://github.com/jnsgruk/libations.
12 '';
13 };
14
15 tailscaleKeyFile = mkOption {
16 type = nullOr path;
17 default = null;
18 example = "/run/agenix/libations-tsauthkey";
19 description = ''
20 A file containing a key for Libations to join a Tailscale network.
21 See https://tailscale.com/kb/1085/auth-keys/.
22 '';
23 };
24
25 package = mkPackageOption pkgs "libations" { };
26 };
27 };
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:
1 config = mkIf cfg.enable {
2 systemd.services.libations = {
3 description = "Libations cocktail recipe viewer";
4 wantedBy = [ "multi-user.target" ];
5 after = [ "network.target" ];
6 environment = {
7 "XDG_CONFIG_HOME" = "/var/lib/libations/";
8 };
9 serviceConfig = {
10 DynamicUser = true;
11 ExecStart = "${cfg.package}/bin/libations -recipes-file ${cfg.recipesFile}";
12 Restart = "always";
13 EnvironmentFile = cfg.tailscaleKeyFile;
14 StateDirectory = "libations";
15 StateDirectoryMode = "0750";
16 };
17 };
18 }
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:
1 { config, self, ... }:
2 {
3 age.secrets = {
4 libations-auth-key = {
5 file = "${self}/secrets/thor-libations-tskey.age";
6 owner = "root";
7 group = "root";
8 mode = "400";
9 };
10
11 libations-recipes = {
12 file = "${self}/secrets/thor-libations-recipes.age";
13 owner = "root";
14 group = "root";
15 mode = "444";
16 };
17 };
18
19 services.libations = {
20 enable = true;
21 recipesFile = config.age.secrets.libations-recipes.path;
22 tailscaleKeyFile = config.age.secrets.libations-auth-key.path;
23 };
24 }
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
!
Jon Seager
Author
Jon Seager
Husband, father, leader, software engineer, geek.
---------------------------------------------------------------------
-- How I Computer in 2024 31 July 2024
|
[ ]