Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configurable mtls #297

Merged
merged 21 commits into from
Feb 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 35 additions & 3 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.",
kradalby marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
17 changes: 17 additions & 0 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
24 changes: 22 additions & 2 deletions cmd/headscale/cli/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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")

Expand Down Expand Up @@ -92,6 +93,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"))
Expand Down Expand Up @@ -281,6 +296,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"),
Expand Down Expand Up @@ -312,8 +331,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,

Expand Down
7 changes: 7 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
```