diff --git a/README.md b/README.md index e249fcc..7932795 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 3466a39..df895ec 100644 --- a/app.go +++ b/app.go @@ -1,11 +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" ) @@ -22,6 +27,13 @@ type Config struct { DBname string DBuser string DBpass string + + TLSLetsEncryptHostname string + TLSLetsEncryptCacheDir string + TLSLetsEncryptChallengeType string + + TLSCertPath string + TLSKeyPath string } // Headscale represents the base app of the service @@ -61,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() @@ -68,6 +86,48 @@ func (h *Headscale) Serve() error { r.GET("/register", h.RegisterWebAPI) r.POST("/machine/:id/map", h.PollNetMapHandler) r.POST("/machine/:id", h.RegistrationHandler) - err := r.Run(h.cfg.Addr) + var err error + 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://") + } + err = r.Run(h.cfg.Addr) + } else { + if !strings.HasPrefix(h.cfg.ServerURL, "https://") { + fmt.Println("WARNING: listening with TLS but ServerURL does not start with https://") + } + err = r.RunTLS(h.cfg.Addr, h.cfg.TLSCertPath, h.cfg.TLSKeyPath) + } return err } diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go index 970aa77..7deb10a 100644 --- a/cmd/headscale/headscale.go +++ b/cmd/headscale/headscale.go @@ -49,7 +49,10 @@ var serveCmd = &cobra.Command{ if err != nil { log.Fatalf("Error initializing: %s", err) } - h.Serve() + err = h.Serve() + if err != nil { + log.Fatalf("Error initializing: %s", err) + } }, } @@ -252,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) @@ -285,7 +308,7 @@ func main() { func absPath(path string) string { // If a relative path is provided, prefix it with the the directory where // the config file was found. - if !strings.HasPrefix(path, "/") { + if (path != "") && !strings.HasPrefix(path, "/") { dir, _ := filepath.Split(viper.ConfigFileUsed()) if dir != "" { path = dir + "/" + path @@ -311,7 +334,15 @@ func getHeadscaleApp() (*headscale.Headscale, error) { DBname: viper.GetString("db_name"), 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")), } + h, err := headscale.NewHeadscale(cfg) if err != nil { return nil, err diff --git a/config.json.example b/config.json.example index 6b4a62a..5811dac 100644 --- a/config.json.example +++ b/config.json.example @@ -7,5 +7,10 @@ "db_port": 5432, "db_name": "headscale", "db_user": "foo", - "db_pass": "bar" + "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 b20125d..d83ecd1 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