old headshot of Tobias Weingartner

Toby “Nutty Swiss” Weingartner


Secure Home Web Services

Created 2024-11-24

I’m spoiled. I (currently) work for Google, and anyone that is (or was) an engineer at Google will tell you that the pure number of browser integrations spoils you to no end. It seems like there are an unlimited number of engineers that create browser extensions and services to make an engineer’s life easier and faster.

One of these ubiquitous services is the “go/” service. Pretty much anyone can create a short link and point it at places that make sense. “go/vacation”, yep, put in for vacation. “go/sick”, yup, you’re sick, put in that sick day. Pretty much anything you can think of, the go link likely exists. Along with the backend go service, there is full integration with the go service across pretty much the full stack of internal Google services. You happen to type a go link into an internal Google Docs document? Of course, it auto-links (turns into a clickable link). These integrations make it exceedingly easy to provide short, memorable, readable, descriptive, and usable links in every context where they are needed most. Of course, many of these internal Google systems have been exported and developed into money making ventures. If you search for “go link shortener”, you will find any number of implementations (free and paid for) that you could install or pay for.

Naturally, being an engineer, following some tutorial to install a number of Ubuntu packages would just not do. I would need to spend a significant amount of effort and time to figure out how do this “on my own”. I mean, I’ve got the required tools: Go, Tailscale, and way too much time (I’m currently recovering from surgery). So let’s see how much of this I can figure out.

What are some of the requirements?

After spending (a future post) too much time replacing my single tiny Ubuntu cloud server with updated software, I installed (as I always do) Tailscale to get access to my tailnet. After working on implementing (another future post) my own Markdown processing pipeline, it occurred to me that having a number of other web services available as short, single word, URL/addresses, would be very nice. I was already using Caddy to implement the new web server, so it seemed like a natural thing to have it also serve the “go.internal” web site, with a reverse proxy to the “go service”. Unfortunately, it seems I’ve run into a “small problem”. The certificate that Tailscale obtains for your tailnet is a Let’s Encrypt certificate. These are issued for all the hosts of your tailnet. However, your tailnet domain name looks like <host>.<tailnet-name>.ts.net, which is not horrible, except that the <tailnet-name> portion is either a somewhat random number, or a couple of random words. Neither is easily rembered. However, one good thing is that if you turn on Magic DNS, <tailnet-name>.ts.net is added to your DNS search list. So if you happen to have a host in your tailnet called go.<tailnet-name>.ts.net, using go would resolve to the IP for go.<tailnet-name>.ts.net. So far so good.

Except, it looked like I needed to stand up the equivalent of a full host (or VM/container) for each short service name that I wished to have exist in my universe. Sub-optimal, my tiny little “free” cloud host with 1G of RAM was not going to be happy running a VM or container, much less 1/2 dozen of them. Standing up multiple tiny cloud VMs was also going to start costing money (hey, I’m cheap… sometimes). So what was an engineer to do? Surely there was a way to stuff multiple web serving entities onto a single Linux host? I mean, in the past we used Host headers and things like SNI to let a single web server act like multiple properties. Surely this was possible without standing up multiple VMs (or containers) and managing their run-time, configs, etc?

After reading a bunch of the Tailscale GitHub code, I managed to create a tiny little piece of Go code that acted like a web server on its on own host, all while being hosted (for now) on my code.<internal>.ts.net (remember, all I type into my browser is code/ and I get a private, locally hosted code-server VSCode instance with AI/LLM/etc) mini-pc located alongside my personal cluster.

So, what does this small piece of code look like? Here it is, less than 100 lines:

 1package main
 2
 3import (
 4	"context"
 5	"fmt"
 6	"html"
 7	"log"
 8	"net/http"
 9	"strings"
10
11	"tailscale.com/tsnet"
12)
13
14func firstLabel(s string) string {
15	s, _, _ = strings.Cut(s, ".")
16	return s
17}
18
19func main() {
20	ctx := context.Background()
21	s := &tsnet.Server{
22		Hostname:     "go",
23		RunWebClient: true,
24	}
25	defer s.Close()
26	st, err := s.Up(ctx)
27	if err != nil {
28		log.Fatalf("Failed to start Tailscale: %v", err)
29	}
30
31	lc, err := s.LocalClient()
32	if err != nil {
33		log.Fatal(err)
34	}
35	netname := st.CurrentTailnet.MagicDNSSuffix
36
37	go func() {
38		ln, err := s.Listen("tcp", ":80")
39		if err != nil {
40			log.Fatal(err)
41		}
42		defer ln.Close()
43
44		fn := func(w http.ResponseWriter, r *http.Request) {
45			// remove/add not default ports from r.Host
46			target := "https://" + r.Host + "." + netname + r.URL.Path
47			if len(r.URL.RawQuery) > 0 {
48				target += "?" + r.URL.RawQuery
49			}
50			log.Printf("redirect to: %s", target)
51			http.Redirect(w, r, target, http.StatusTemporaryRedirect)
52		}
53
54		log.Println("Starting :80")
55		log.Fatal(http.Serve(ln, http.HandlerFunc(fn)))
56	}()
57
58	go func() {
59		ns, err := s.ListenTLS("tcp", ":443")
60		if err != nil {
61			log.Fatal(err)
62		}
63		defer ns.Close()
64
65		fn := func(w http.ResponseWriter, r *http.Request) {
66			who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
67			if err != nil {
68				http.Error(w, err.Error(), 500)
69				return
70			}
71			fmt.Fprintf(w, "<html><body><h1>Hello, tailnet!</h1>\n")
72			fmt.Fprintf(w, "<p>You are <b>%s</b> from <b>%s</b> (%s)</p>",
73				html.EscapeString(who.UserProfile.LoginName),
74				html.EscapeString(firstLabel(who.Node.ComputedName)),
75				r.RemoteAddr)
76		}
77
78		log.Println("Starting :443")
79		log.Fatal(http.Serve(ns, http.HandlerFunc(fn)))
80	}()
81
82	select {}
83}

That’s it. Running this as go run . writes some log lines asking you to login to your Tailscale account and register a new host (called go). After you do that, this process becomes a new tailnet host, complete with it’s own IP address and TLS certificate. There seems to be only a single down side at this point, the certificate is only for the fully qualified domain name, and does not include short name of the host as an alternate name. As such, if you enter https://go/ (note the HTTPS), Chrome will complain about the certificate being for a different host. However, it should be easy enough issue a HTTP 301/302 redirect to the full host with a TLS connection. While not perfect, it’s “close enough”. At least I can now enter go/ in the browser, for example, go/music will redirect me to a music player within my home. Naturally, I now also have the option of creating as many (well, Tailscale allows for 100 hosts for free!) hosts within my tailnet as I can think of. So in the future, maybe music/ will be enough to get to the same place… 🤷‍♂

What else? Well, if I install the Tailscale client for the public WiFi portion of my in-home and serve up a local DNS server, I can provide a similar, albeit separate, set of services to anyone visiting me. Using things like a Tailscale funnel, I could also export any service to members of my family, or other conspirators that I choose to involve.

Note: Implementation of the database backend is left as an exercise to the reader. MySQL and the Go MySQL would likely make a fairly implementation that stores everything within a single directory hierarchy, is easy to back up and move around.