Merge pull request #9 from cure/add-tls-autocert

Add support for TLS to the web service.
This commit is contained in:
Juan Font 2021-04-24 11:28:31 +02:00 committed by GitHub
commit d30b682a6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 150 additions and 6 deletions

View file

@ -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

62
app.go
View file

@ -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
}

View file

@ -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

View file

@ -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": ""
}

3
go.mod
View file

@ -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