diff --git a/CHANGELOG.md b/CHANGELOG.md index 61314971..a489421e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ This is a part of aligning `headscale`'s behaviour with Tailscale's upstream beh - Tags should now work correctly and adding a host to Headscale should now reload the rules. - The documentation have a [fictional example](docs/acls.md) that should cover some use cases of the ACLs features +**Features**: + +- Add support for configurable mTLS [docs](docs/tls.md#configuring-mutual-tls-authentication-mtls) [#297](https://github.com/juanfont/headscale/pull/297) + **Changes**: - Remove dependency on CGO (switch from CGO SQLite to pure Go) [#346](https://github.com/juanfont/headscale/pull/346) diff --git a/app.go b/app.go index fa1f5a08..bea4cf8e 100644 --- a/app.go +++ b/app.go @@ -62,6 +62,10 @@ const ( errUnsupportedLetsEncryptChallengeType = Error( "unknown value for Lets Encrypt challenge type", ) + + DisabledClientAuth = "disabled" + RelaxedClientAuth = "relaxed" + EnforcedClientAuth = "enforced" ) // Config contains the initial Headscale configuration. @@ -90,8 +94,9 @@ type Config struct { TLSLetsEncryptCacheDir string TLSLetsEncryptChallengeType string - TLSCertPath string - TLSKeyPath string + TLSCertPath string + TLSKeyPath string + TLSClientAuthMode tls.ClientAuthType ACMEURL string ACMEEmail string @@ -150,6 +155,27 @@ type Headscale struct { requestedExpiryCache *cache.Cache } +// Look up the TLS constant relative to user-supplied TLS client +// authentication mode. If an unknown mode is supplied, the default +// value, tls.RequireAnyClientCert, is returned. The returned boolean +// indicates if the supplied mode was valid. +func LookupTLSClientAuthMode(mode string) (tls.ClientAuthType, bool) { + switch mode { + case DisabledClientAuth: + // Client cert is _not_ required. + return tls.NoClientCert, true + case RelaxedClientAuth: + // Client cert required, but _not verified_. + return tls.RequireAnyClientCert, true + case EnforcedClientAuth: + // Client cert is _required and verified_. + return tls.RequireAndVerifyClientCert, true + default: + // Return the default when an unknown value is supplied. + return tls.RequireAnyClientCert, false + } +} + // NewHeadscale returns the Headscale app. func NewHeadscale(cfg Config) (*Headscale, error) { privKey, err := readOrCreatePrivateKey(cfg.PrivateKeyPath) @@ -676,12 +702,18 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) { if !strings.HasPrefix(h.cfg.ServerURL, "https://") { log.Warn().Msg("Listening with TLS but ServerURL does not start with https://") } + + log.Info().Msg(fmt.Sprintf( + "Client authentication (mTLS) is \"%s\". See the docs to learn about configuring this setting.", + h.cfg.TLSClientAuthMode)) + tlsConfig := &tls.Config{ - ClientAuth: tls.RequireAnyClientCert, + ClientAuth: h.cfg.TLSClientAuthMode, NextProtos: []string{"http/1.1"}, Certificates: make([]tls.Certificate, 1), MinVersion: tls.VersionTLS12, } + tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLSCertPath, h.cfg.TLSKeyPath) return tlsConfig, err diff --git a/app_test.go b/app_test.go index 02fdce8b..53c703a6 100644 --- a/app_test.go +++ b/app_test.go @@ -65,3 +65,20 @@ func (s *Suite) ResetDB(c *check.C) { } app.db = db } + +// Enusre an error is returned when an invalid auth mode +// is supplied. +func (s *Suite) TestInvalidClientAuthMode(c *check.C) { + _, isValid := LookupTLSClientAuthMode("invalid") + c.Assert(isValid, check.Equals, false) +} + +// Ensure that all client auth modes return a nil error. +func (s *Suite) TestAuthModes(c *check.C) { + modes := []string{"disabled", "relaxed", "enforced"} + + for _, v := range modes { + _, isValid := LookupTLSClientAuthMode(v) + c.Assert(isValid, check.Equals, true) + } +} diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index d38bb260..97c2440e 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -49,6 +49,7 @@ func LoadConfig(path string) error { viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache") viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01") + viper.SetDefault("tls_client_auth_mode", "relaxed") viper.SetDefault("log_level", "info") @@ -93,6 +94,20 @@ func LoadConfig(path string) error { !strings.HasPrefix(viper.GetString("server_url"), "https://") { errorText += "Fatal config error: server_url must start with https:// or http://\n" } + + _, authModeValid := headscale.LookupTLSClientAuthMode( + viper.GetString("tls_client_auth_mode"), + ) + + if !authModeValid { + errorText += fmt.Sprintf( + "Invalid tls_client_auth_mode supplied: %s. Accepted values: %s, %s, %s.", + viper.GetString("tls_client_auth_mode"), + headscale.DisabledClientAuth, + headscale.RelaxedClientAuth, + headscale.EnforcedClientAuth) + } + if errorText != "" { //nolint return errors.New(strings.TrimSuffix(errorText, "\n")) @@ -282,6 +297,10 @@ func getHeadscaleConfig() headscale.Config { Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes) } + tlsClientAuthMode, _ := headscale.LookupTLSClientAuthMode( + viper.GetString("tls_client_auth_mode"), + ) + return headscale.Config{ ServerURL: viper.GetString("server_url"), Addr: viper.GetString("listen_addr"), @@ -313,8 +332,9 @@ func getHeadscaleConfig() headscale.Config { ), TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"), - TLSCertPath: absPath(viper.GetString("tls_cert_path")), - TLSKeyPath: absPath(viper.GetString("tls_key_path")), + TLSCertPath: absPath(viper.GetString("tls_cert_path")), + TLSKeyPath: absPath(viper.GetString("tls_key_path")), + TLSClientAuthMode: tlsClientAuthMode, DNSConfig: dnsConfig, diff --git a/config-example.yaml b/config-example.yaml index 71fdfaaf..bac3b0ca 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -105,6 +105,13 @@ acme_email: "" # Domain name to request a TLS certificate for: tls_letsencrypt_hostname: "" +# Client (Tailscale/Browser) authentication mode (mTLS) +# Acceptable values: +# - disabled: client authentication disabled +# - relaxed: client certificate is required but not verified +# - enforced: client certificate is required and verified +tls_client_auth_mode: relaxed + # Path to store certificates and metadata needed by # letsencrypt tls_letsencrypt_cache_dir: /var/lib/headscale/cache diff --git a/docs/tls.md b/docs/tls.md index 557cdf01..c319359a 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -29,3 +29,17 @@ headscale can also be configured to expose its web service via TLS. To configure tls_cert_path: "" tls_key_path: "" ``` + +### Configuring Mutual TLS Authentication (mTLS) + +mTLS is a method by which an HTTPS server authenticates clients, e.g. Tailscale, using TLS certificates. This can be configured by applying one of the following values to the `tls_client_auth_mode` setting in the configuration file. + +| Value | Behavior | +| ------------------- | ---------------------------------------------------------- | +| `disabled` | Disable mTLS. | +| `relaxed` (default) | A client certificate is required, but it is not verified. | +| `enforced` | Requires clients to supply a certificate that is verified. | + +```yaml +tls_client_auth_mode: "" +```