Skip to content

Commit

Permalink
Adds ACME - auto cert management
Browse files Browse the repository at this point in the history
This commit fixes #5177

Initial implementation uses dir backend as a cache and is OK
for small clusters, but will be a problem for many proxies.

This implementation uses Go autocert that is quite limited
compared to Caddy's certmagic or lego.

Autocert has no OCSP stapling and no locking for cache for example.
However, it is much simpler and has no dependencies.
It will be easier to extend to use Teleport backend as a cert cache.

```yaml
proxy_service:
  public_addr: ['example.com']
  # ACME - automatic certificate management environment.
  #
  # It provisions certificates for domains and
  # valid subdomains in public_addr section.
  #
  # The sudomains are valid if there is a registered application.
  # For example, app.example.com will get a cert if app is a regsitered
  # application access app. The sudomain cookie.example.com is not.
  #
  # Teleport acme is using TLS-ALPN-01 challenge:
  #
  # https://letsencrypt.org/docs/challenge-types/#tls-alpn-01
  #
  acme:
    # By default acme is disabled.
    enabled: true
    # Use a custom URI, for example staging is
    #
    # https://acme-staging-v02.api.letsencrypt.org/directory
    #
    # Default is letsencrypt.org production URL:
    #
    # https://acme-v02.api.letsencrypt.org/directory
    uri: ''
    # Set email to receive alerts and other correspondence
    # from your certificate authority.
    email: '[email protected]'
```
  • Loading branch information
klizhentas committed Dec 23, 2020
1 parent 96019ce commit c0bb732
Show file tree
Hide file tree
Showing 20 changed files with 4,557 additions and 53 deletions.
3 changes: 3 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ const (
// ComponentServer is a server subcomponent of some services
ComponentServer = "server"

// ComponentACME is ACME protocol controller
ComponentACME = "acme"

// ComponentReverseTunnelServer is reverse tunnel server
// that together with agent establish a bi-directional SSH revers tunnel
// to bypass firewall restrictions
Expand Down
6 changes: 6 additions & 0 deletions lib/config/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,12 @@ func applyProxyConfig(fc *FileConfig, cfg *service.Config) error {
cfg.Proxy.TunnelPublicAddrs = addrs
}

acme, err := fc.Proxy.ACME.Parse()
if err != nil {
return trace.Wrap(err)
}
cfg.Proxy.ACME = *acme

return nil

}
Expand Down
42 changes: 42 additions & 0 deletions lib/config/fileconf.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import (
"fmt"
"io"
"io/ioutil"
"net/url"
"os"
"strings"
"time"

"golang.org/x/crypto/acme"
"golang.org/x/crypto/ssh"

"github.com/gravitational/teleport"
Expand Down Expand Up @@ -179,6 +181,8 @@ var (
"rewrite": false,
"redirect": false,
"debug_app": false,
"acme": true,
"email": false,
}
)

Expand Down Expand Up @@ -941,6 +945,44 @@ type Proxy struct {

// KeyPairs is a list of x509 key pairs the proxy will load.
KeyPairs []KeyPair `yaml:"https_keypairs"`

// ACME configures ACME protocol support
ACME ACME `yaml:"acme"`
}

// ACME configures ACME protocol - automatic X.509 certificates
type ACME struct {
// EnabledFlag is whether ACME should be enabled
EnabledFlag string `yaml:"enabled,omitempty"`
// Email is the email that will receive problems with certificate renewals
Email string `yaml:"email,omitempty"`
// URI is ACME server URI
URI string `yaml:"uri,omitempty"`
}

// Parse parses ACME section values
func (a ACME) Parse() (*service.ACME, error) {
// ACME is disabled by default
out := service.ACME{}
if a.EnabledFlag == "" {
return &out, nil
}

var err error
out.Enabled, err = utils.ParseBool(a.EnabledFlag)
if err != nil {
return nil, trace.Wrap(err)
}
out.Email = a.Email
if a.URI != "" {
_, err := url.Parse(a.URI)
if err != nil {
return nil, trace.Wrap(err, "acme.uri should be a valid URI, for example %v", acme.LetsEncryptURL)
}
}
out.URI = a.URI

return &out, nil
}

// KeyPair represents a path on disk to a private key and certificate.
Expand Down
124 changes: 124 additions & 0 deletions lib/service/acme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
Copyright 2015-2020 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package service

import (
"context"
"net"
"strings"

"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/web/app"

"github.com/gravitational/trace"
)

type hostPolicyCheckerConfig struct {
// publicAddrs is a list of pubic addresses to support acme for
publicAddrs []utils.NetAddr
// clt is used to get the list of registered applications
clt app.Getter
// tun is a reverse tunnel
tun reversetunnel.Tunnel
// clusterName is a name of this cluster
clusterName string
}

type hostPolicyChecker struct {
dnsNames []string
cfg hostPolicyCheckerConfig
}

// checkHost approves getting certs for hosts specified in public_addr
// and their subdomains, if there is a valid application name registered
func (h *hostPolicyChecker) checkHost(ctx context.Context, host string) error {
if ip := net.ParseIP(host); ip != nil {
return trace.BadParameter(
"with proxy_service.acme on, IP URL https://%v is not supported, use one of the domains in proxy_service.public_addr: %v",
host, strings.Join(h.dnsNames, ","))
}

var couldNotMatchApp bool
for _, dnsName := range h.dnsNames {
if dnsName == host {
return nil
}
// if it's a subdomain, allow it if application access
// has the name that matches the fqdn
if strings.HasSuffix(host, "."+dnsName) {
_, _, _, err := app.ResolveFQDN(ctx, h.cfg.clt, h.cfg.tun, h.cfg.clusterName, host)
if err == nil {
return nil
}
if !trace.IsNotFound(err) {
return trace.Wrap(err)
}
couldNotMatchApp = true
}
}

if couldNotMatchApp {
return trace.BadParameter(
"acme can't get a cert for %v, there is no app with this name", host)
}

return trace.BadParameter(
"acme can't get a cert for domain %v, add it to the proxy_service.public_addr, or use one of the domains: %v",
host, strings.Join(h.dnsNames, ","))
}

func newHostPolicyChecker(cfg hostPolicyCheckerConfig) (*hostPolicyChecker, error) {
dnsNames, err := cfg.CheckAndSetDefaults()
if err != nil {
return nil, trace.Wrap(err)
}

return &hostPolicyChecker{
dnsNames: dnsNames,
cfg: cfg,
}, nil
}

func (h *hostPolicyCheckerConfig) CheckAndSetDefaults() ([]string, error) {
if h.clt == nil {
return nil, trace.BadParameter("missing parameter clt")
}

if h.tun == nil {
return nil, trace.BadParameter("missing parameter tun")
}

dnsNames := make([]string, 0, len(h.publicAddrs))

for _, addr := range h.publicAddrs {
host, err := utils.Host(addr.Addr)
if err != nil {
return nil, trace.Wrap(err)
}
if ip := net.ParseIP(host); ip == nil {
dnsNames = append(dnsNames, host)
}
}

if len(dnsNames) == 0 {
return nil, trace.BadParameter(
"acme is enabled, set at least one valid DNS name in public_addr section of proxy_service")
}

return dnsNames, nil
}
13 changes: 13 additions & 0 deletions lib/service/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,19 @@ type ProxyConfig struct {

// KeyPairs are the key and certificate pairs that the proxy will load.
KeyPairs []KeyPairPath

// ACME is ACME protocol support config
ACME ACME
}

// ACME configures ACME automatic certificate renewal
type ACME struct {
// Enabled enables or disables ACME support
Enabled bool
// Email receives notifications from ACME server
Email string
// URI is ACME server URI
URI string
}

// KeyPairPath are paths to a key and certificate file.
Expand Down
38 changes: 36 additions & 2 deletions lib/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import (
"sync/atomic"
"time"

"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/crypto/ssh"

"github.com/gravitational/teleport"
Expand Down Expand Up @@ -2122,7 +2124,8 @@ func (process *TeleportProcess) initProxy() error {
// If no TLS key was provided for the web UI, generate a self signed cert
if len(process.Config.Proxy.KeyPairs) == 0 &&
!process.Config.Proxy.DisableTLS &&
!process.Config.Proxy.DisableWebService {
!process.Config.Proxy.DisableWebService &&
!process.Config.Proxy.ACME.Enabled {
err := initSelfSignedHTTPSCert(process.Config)
if err != nil {
return trace.Wrap(err)
Expand Down Expand Up @@ -2428,7 +2431,38 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error {
}
proxyLimiter.WrapHandle(webHandler)
if !process.Config.Proxy.DisableTLS {
tlsConfig := utils.TLSConfig(cfg.CipherSuites)
var tlsConfig *tls.Config
acmeCfg := process.Config.Proxy.ACME
if !acmeCfg.Enabled {
tlsConfig = utils.TLSConfig(cfg.CipherSuites)
} else {
log.Infof("Managing certs using ACME https://datatracker.ietf.org/doc/rfc8555/.")

acmePath := filepath.Join(process.Config.DataDir, teleport.ComponentACME)
if err := os.MkdirAll(acmePath, teleport.PrivateDirMode); err != nil {
return trace.ConvertSystemError(err)
}
hostChecker, err := newHostPolicyChecker(hostPolicyCheckerConfig{
publicAddrs: process.Config.Proxy.PublicAddrs,
clt: conn.Client,
tun: tsrv,
clusterName: conn.ServerIdentity.Cert.Extensions[utils.CertExtensionAuthority],
})
if err != nil {
return trace.Wrap(err)
}
m := &autocert.Manager{
Cache: autocert.DirCache(acmePath),
Prompt: autocert.AcceptTOS,
HostPolicy: hostChecker.checkHost,
Email: acmeCfg.Email,
}
if acmeCfg.URI != "" {
m.Client = &acme.Client{DirectoryURL: acmeCfg.URI}
}
tlsConfig = m.TLSConfig()
utils.SetupTLSConfig(tlsConfig, cfg.CipherSuites)
}

for _, pair := range process.Config.Proxy.KeyPairs {
log.Infof("Loading TLS certificate %v and key %v.", pair.Certificate, pair.PrivateKey)
Expand Down
6 changes: 5 additions & 1 deletion lib/utils/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import (
// TLSConfig returns default TLS configuration strong defaults.
func TLSConfig(cipherSuites []uint16) *tls.Config {
config := &tls.Config{}
SetupTLSConfig(config, cipherSuites)
return config
}

// SetupTLSConfig sets up cipher suites in existing TLS config
func SetupTLSConfig(config *tls.Config, cipherSuites []uint16) {
// If ciphers suites were passed in, use them. Otherwise use the the
// Go defaults.
if len(cipherSuites) > 0 {
Expand All @@ -52,7 +57,6 @@ func TLSConfig(cipherSuites []uint16) *tls.Config {
config.SessionTicketsDisabled = false
config.ClientSessionCache = tls.NewLRUClientSessionCache(
DefaultLRUCapacity)
return config
}

// CreateTLSConfiguration sets up default TLS configuration
Expand Down
61 changes: 59 additions & 2 deletions lib/web/app/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ package app
import (
"context"
"math/rand"
"strings"

"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"

"github.com/gravitational/trace"
)
Expand All @@ -36,7 +39,7 @@ import (
//
// In the future this function should be updated to keep state on application
// servers that are down and to not route requests to that server.
func Match(ctx context.Context, authClient appGetter, fn Matcher) (*services.App, services.Server, error) {
func Match(ctx context.Context, authClient Getter, fn Matcher) (*services.App, services.Server, error) {
servers, err := authClient.GetAppServers(ctx, defaults.Namespace)
if err != nil {
return nil, nil, trace.Wrap(err)
Expand Down Expand Up @@ -78,6 +81,60 @@ func MatchName(name string) Matcher {
}
}

type appGetter interface {
// Getter returns a list of registered apps
type Getter interface {
// GetAppServers returns a list of app servers
GetAppServers(context.Context, string, ...services.MarshalOption) ([]services.Server, error)
}

// ResolveFQDN makes a best effort attempt to resolve FQDN to an application
// running a root or leaf cluster.
//
// Note: This function can incorrectly resolve application names. For example,
// if you have an application named "acme" within both the root and leaf
// cluster, this method will always return "acme" running within the root
// cluster. Always supply public address and cluster name to deterministically
// resolve an application.
func ResolveFQDN(ctx context.Context, clt Getter, tunnel reversetunnel.Tunnel, clusterName string, fqdn string) (*services.App, services.Server, string, error) {
// Parse the address to remove the port if it's set.
addr, err := utils.ParseAddr(fqdn)
if err != nil {
return nil, nil, "", trace.Wrap(err)
}

// Try and match FQDN to public address of application within cluster.
application, server, err := Match(ctx, clt, MatchPublicAddr(addr.Host()))
if err == nil {
return application, server, clusterName, nil
}

// Extract the first subdomain from the FQDN and attempt to use this as the
// application name.
appName := strings.Split(addr.Host(), ".")[0]

// Try and match application name to an application within the cluster.
application, server, err = Match(ctx, clt, MatchName(appName))
if err == nil {
return application, server, clusterName, nil
}

// Loop over all clusters and try and match application name to an
// application with the cluster.
remoteClients, err := tunnel.GetSites()
if err != nil {
return nil, nil, "", trace.Wrap(err)
}
for _, remoteClient := range remoteClients {
authClient, err := remoteClient.CachingAccessPoint()
if err != nil {
return nil, nil, "", trace.Wrap(err)
}

application, server, err = Match(ctx, authClient, MatchName(appName))
if err == nil {
return application, server, remoteClient.GetName(), nil
}
}

return nil, nil, "", trace.NotFound("failed to resolve %v to any application within any cluster", fqdn)
}
Loading

0 comments on commit c0bb732

Please sign in to comment.