From 426b4fd98a604b7eea0811e506b60bec19b9f8e7 Mon Sep 17 00:00:00 2001 From: Ward Vandewege Date: Fri, 23 Apr 2021 22:54:15 -0400 Subject: [PATCH] Add support for automatic TLS certificates via Let's Encrypt. Add a configuration reference to the README.md file. --- README.md | 49 ++++++++++++++++++++++++++++++++++++++ app.go | 47 +++++++++++++++++++++++++++++++++++- cmd/headscale/headscale.go | 24 +++++++++++++++++++ config.json.example | 3 +++ go.mod | 3 +-- 5 files changed, 123 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e249fcc9..7932795f 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,55 @@ Suggestions/PRs welcomed! ./headscale register YOURMACHINEKEY myfirstnamespace ``` +## Configuration reference + +Headscale's configuration file is named `config.json` or `config.yaml`. Headscale will look for it in `/etc/headscale`, `~/.headscale` and finally the directory from where the Headscale binary is executed. + +``` + "server_url": "http://192.168.1.12:8000", + "listen_addr": "0.0.0.0:8000", +``` + +`server_url` is the external URL via which Headscale is reachable. `listen_addr` is the IP address and port the Headscale program should listen on. + +``` + "private_key_path": "private.key", +``` + +`private_key_path` is the path to the Wireguard private key. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. + +``` + "derp_map_path": "derp.yaml", +``` + +`derp_map_path` is the path to the [DERP](https://pkg.go.dev/tailscale.com/derp) map file. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. + +``` + "db_host": "localhost", + "db_port": 5432, + "db_name": "headscale", + "db_user": "foo", + "db_pass": "bar", +``` + +The fields starting with `db_` are used for the PostgreSQL connection information. + +### Running the service via TLS (optional) + +``` + "tls_cert_path": "" + "tls_key_path": "" +``` + +Headscale can be configured to expose its web service via TLS. To configure the certificate and key file manually, set the `tls_cert_path` and `tls_cert_path` configuration parameters. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. + +``` + "tls_letsencrypt_hostname": "", + "tls_letsencrypt_cache_dir": ".cache", + "tls_letsencrypt_challenge_type": "HTTP-01", +``` + +To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/), set `tls_letsencrypt_hostname` to the desired certificate hostname. This name must resolve to the IP address(es) Headscale is reachable on (i.e., it must correspond to the `server_url` configuration parameter). The certificate and Let's Encrypt account credentials will be stored in the directory configured in `tls_letsencrypt_cache_dir`. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. The certificate will automatically be renewed as needed. The default challenge type HTTP-01 requires that Headscale listens on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in `listen_addr`. Alternatively, `tls_letsencrypt_challenge_type` can be set to `TLS-ALPN-01`. In this configuration, Headscale must be reachable via port 443, but port 80 is not required. ## Disclaimer diff --git a/app.go b/app.go index 7391dd19..df895ec1 100644 --- a/app.go +++ b/app.go @@ -1,12 +1,16 @@ package headscale import ( + "errors" "fmt" + "log" + "net/http" "os" "strings" "sync" "github.com/gin-gonic/gin" + "golang.org/x/crypto/acme/autocert" "tailscale.com/tailcfg" "tailscale.com/wgengine/wgcfg" ) @@ -24,6 +28,10 @@ type Config struct { DBuser string DBpass string + TLSLetsEncryptHostname string + TLSLetsEncryptCacheDir string + TLSLetsEncryptChallengeType string + TLSCertPath string TLSKeyPath string } @@ -65,6 +73,12 @@ func NewHeadscale(cfg Config) (*Headscale, error) { return &h, nil } +// Redirect to our TLS url +func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) { + target := h.cfg.ServerURL + req.URL.RequestURI() + http.Redirect(w, req, target, http.StatusFound) +} + // Serve launches a GIN server with the Headscale API func (h *Headscale) Serve() error { r := gin.Default() @@ -73,7 +87,38 @@ func (h *Headscale) Serve() error { r.POST("/machine/:id/map", h.PollNetMapHandler) r.POST("/machine/:id", h.RegistrationHandler) var err error - if h.cfg.TLSCertPath == "" { + if h.cfg.TLSLetsEncryptHostname != "" { + if !strings.HasPrefix(h.cfg.ServerURL, "https://") { + fmt.Println("WARNING: listening with TLS but ServerURL does not start with https://") + } + + m := autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(h.cfg.TLSLetsEncryptHostname), + Cache: autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir), + } + s := &http.Server{ + Addr: h.cfg.Addr, + TLSConfig: m.TLSConfig(), + Handler: r, + } + if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" { + // Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737) + // The RFC requires that the validation is done on port 443; in other words, headscale + // must be configured to run on port 443. + err = s.ListenAndServeTLS("", "") + } else if h.cfg.TLSLetsEncryptChallengeType == "HTTP-01" { + // Configuration via autocert with HTTP-01. This requires listening on + // port 80 for the certificate validation in addition to the headscale + // service, which can be configured to run on any other port. + go func() { + log.Fatal(http.ListenAndServe(":http", m.HTTPHandler(http.HandlerFunc(h.redirect)))) + }() + } else { + return errors.New("Unknown value for TLSLetsEncryptChallengeType") + } + err = s.ListenAndServeTLS("", "") + } else if h.cfg.TLSCertPath == "" { if !strings.HasPrefix(h.cfg.ServerURL, "http://") { fmt.Println("WARNING: listening without TLS but ServerURL does not start with http://") } diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go index bedfdb92..7deb10a9 100644 --- a/cmd/headscale/headscale.go +++ b/cmd/headscale/headscale.go @@ -255,11 +255,31 @@ func main() { viper.AddConfigPath("$HOME/.headscale") viper.AddConfigPath(".") viper.AutomaticEnv() + + viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache") + viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01") + err := viper.ReadInConfig() if err != nil { log.Fatalf("Fatal error config file: %s \n", err) } + if (viper.GetString("tls_letsencrypt_hostname") != "") && ((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) { + log.Fatalf("Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both") + } + + if (viper.GetString("tls_letsencrypt_hostname") != "") && (viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") && (!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) { + log.Fatalf("Fatal config error: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, listen_addr must end in :443") + } + + if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") && (viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") { + log.Fatalf("Fatal config error: the only supported values for tls_letsencrypt_challenge_type are HTTP-01 and TLS-ALPN-01") + } + + if !strings.HasPrefix(viper.GetString("server_url"), "http://") && !strings.HasPrefix(viper.GetString("server_url"), "https://") { + log.Fatalf("Fatal config error: server_url must start with https:// or http://") + } + headscaleCmd.AddCommand(versionCmd) headscaleCmd.AddCommand(serveCmd) headscaleCmd.AddCommand(registerCmd) @@ -315,6 +335,10 @@ func getHeadscaleApp() (*headscale.Headscale, error) { DBuser: viper.GetString("db_user"), DBpass: viper.GetString("db_pass"), + TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"), + TLSLetsEncryptCacheDir: absPath(viper.GetString("tls_letsencrypt_cache_dir")), + TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"), + TLSCertPath: absPath(viper.GetString("tls_cert_path")), TLSKeyPath: absPath(viper.GetString("tls_key_path")), } diff --git a/config.json.example b/config.json.example index faee7a80..5811daca 100644 --- a/config.json.example +++ b/config.json.example @@ -8,6 +8,9 @@ "db_name": "headscale", "db_user": "foo", "db_pass": "bar", + "tls_letsencrypt_hostname": "", + "tls_letsencrypt_cache_dir": ".cache", + "tls_letsencrypt_challenge_type": "HTTP-01", "tls_cert_path": "", "tls_key_path": "" } diff --git a/go.mod b/go.mod index b20125d1..d83ecd1a 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,8 @@ module github.com/juanfont/headscale go 1.16 require ( - github.com/davecgh/go-spew v1.1.1 github.com/gin-gonic/gin v1.6.3 - github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd // indirect + github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd github.com/jinzhu/gorm v1.9.16 github.com/klauspost/compress v1.11.12 github.com/spf13/cobra v1.1.3