From 38d7011487b90892662b92089763a6349d230d77 Mon Sep 17 00:00:00 2001 From: Kevin Pollet Date: Fri, 30 Sep 2022 15:20:08 +0200 Subject: [PATCH] Add Tailscale certificate resolver Co-authored-by: Mathieu Lonjaret --- cmd/traefik/traefik.go | 41 +- docs/content/https/tailscale.md | 237 ++++++++++++ .../reference/static-configuration/cli-ref.md | 3 + .../reference/static-configuration/env-ref.md | 3 + .../reference/static-configuration/file.toml | 20 +- .../reference/static-configuration/file.yaml | 21 +- docs/mkdocs.yml | 1 + go.mod | 2 + go.sum | 4 + pkg/config/static/static_config.go | 9 +- pkg/provider/tailscale/provider.go | 366 ++++++++++++++++++ pkg/provider/tailscale/provider_test.go | 279 +++++++++++++ pkg/tls/certificate.go | 19 +- 13 files changed, 957 insertions(+), 48 deletions(-) create mode 100644 docs/content/https/tailscale.md create mode 100644 pkg/provider/tailscale/provider.go create mode 100644 pkg/provider/tailscale/provider_test.go diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index f54477375..f6866aa59 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -35,6 +35,7 @@ import ( "github.com/traefik/traefik/v2/pkg/provider/acme" "github.com/traefik/traefik/v2/pkg/provider/aggregator" "github.com/traefik/traefik/v2/pkg/provider/hub" + "github.com/traefik/traefik/v2/pkg/provider/tailscale" "github.com/traefik/traefik/v2/pkg/provider/traefik" "github.com/traefik/traefik/v2/pkg/safe" "github.com/traefik/traefik/v2/pkg/server" @@ -191,6 +192,10 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err acmeProviders := initACMEProvider(staticConfiguration, &providerAggregator, tlsManager, httpChallengeProvider, tlsChallengeProvider) + // Tailscale + + tsProviders := initTailscaleProviders(staticConfiguration, &providerAggregator) + // Entrypoints serverEntryPointsTCP, err := server.NewTCPEntryPoints(staticConfiguration.EntryPoints, staticConfiguration.HostResolver) @@ -313,13 +318,22 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err // TLS challenge watcher.AddListener(tlsChallengeProvider.ListenConfiguration) - // ACME + // Certificate Resolvers + resolverNames := map[string]struct{}{} + + // ACME for _, p := range acmeProviders { resolverNames[p.ResolverName] = struct{}{} watcher.AddListener(p.ListenConfiguration) } + // Tailscale + for _, p := range tsProviders { + resolverNames[p.ResolverName] = struct{}{} + watcher.AddListener(p.HandleConfigUpdate) + } + // Certificate resolver logs watcher.AddListener(func(config dynamic.Configuration) { for rtName, rt := range config.HTTP.Routers { @@ -331,7 +345,7 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err // "traefik-hub" is an allowed certificate resolver name in a Traefik Hub Experimental feature context. // It is used to activate its own certificate resolution, even though it is not a "classical" traefik certificate resolver. (staticConfiguration.Hub == nil || rt.TLS.CertResolver != "traefik-hub") { - log.WithoutContext().Errorf("the router %s uses a non-existent resolver: %s", rtName, rt.TLS.CertResolver) + log.WithoutContext().Errorf("Router %s uses a non-existent certificate resolver: %s", rtName, rt.TLS.CertResolver) } } }) @@ -384,7 +398,7 @@ func switchRouter(routerFactory *server.RouterFactory, serverEntryPointsTCP serv } } -// initACMEProvider creates an acme provider from the ACME part of globalConfiguration. +// initACMEProvider creates and registers acme.Provider instances corresponding to the configured ACME certificate resolvers. func initACMEProvider(c *static.Configuration, providerAggregator *aggregator.ProviderAggregator, tlsManager *traefiktls.Manager, httpChallengeProvider, tlsChallengeProvider challenge.Provider) []*acme.Provider { localStores := map[string]*acme.LocalStore{} @@ -421,6 +435,27 @@ func initACMEProvider(c *static.Configuration, providerAggregator *aggregator.Pr return resolvers } +// initTailscaleProviders creates and registers tailscale.Provider instances corresponding to the configured Tailscale certificate resolvers. +func initTailscaleProviders(cfg *static.Configuration, providerAggregator *aggregator.ProviderAggregator) []*tailscale.Provider { + var providers []*tailscale.Provider + for name, resolver := range cfg.CertificatesResolvers { + if resolver.Tailscale == nil { + continue + } + + tsProvider := &tailscale.Provider{ResolverName: name} + + if err := providerAggregator.AddProvider(tsProvider); err != nil { + log.WithoutContext().Errorf("Unable to create Tailscale provider %s: %v", name, err) + continue + } + + providers = append(providers, tsProvider) + } + + return providers +} + func registerMetricClients(metricsConfig *types.Metrics) []metrics.Registry { if metricsConfig == nil { return nil diff --git a/docs/content/https/tailscale.md b/docs/content/https/tailscale.md new file mode 100644 index 000000000..abc742b83 --- /dev/null +++ b/docs/content/https/tailscale.md @@ -0,0 +1,237 @@ +--- +title: "Traefik Tailscale Documentation" +description: "Learn how to configure Traefik Proxy to resolve TLS certificates for your Tailscale services. Read the technical documentation." +--- + +# Tailscale + +Provision TLS certificates for your internal Tailscale services. +{: .subtitle } + +To protect a service with TLS, a certificate from a public Certificate Authority is needed. +In addition to its vpn role, Tailscale can also [provide certificates](https://tailscale.com/kb/1153/enabling-https/) for the machines in your Tailscale network. + +## Certificate resolvers + +To obtain a TLS certificate from the Tailscale daemon, +a Tailscale certificate resolver needs to be configured as below. + +!!! info "Referencing a certificate resolver" + + Defining a certificate resolver does not imply that routers are going to use it automatically. + Each router or entrypoint that is meant to use the resolver must explicitly [reference](../routing/routers/index.md#certresolver) it. + +```yaml tab="File (YAML)" +certificatesResolvers: + myresolver: + tailscale: {} +``` + +```toml tab="File (TOML)" +[certificatesResolvers.myresolver.tailscale] +``` + +```bash tab="CLI" +--certificatesresolvers.myresolver.tailscale=true +``` + +## Domain Definition + +A certificate resolver requests certificates for a set of domain names inferred from routers, according to the following: + +- If the router has a [`tls.domains`](../routing/routers/index.md#domains) option set, + then the certificate resolver derives this router domain name from the `main` option of `tls.domains`. + +- Otherwise, the certificate resolver derives the domain name from any `Host()` or `HostSNI()` matchers + in the [router's rule](../routing/routers/index.md#rule). + +!!! info "Tailscale Domain Format" + + The domain is only taken into account if it is a Tailscale-specific one, + i.e. of the form `machine-name.domains-alias.ts.net`. + +## Configuration Example + +!!! example "Enabling Tailscale certificate resolution" + + ```yaml tab="File (YAML)" + entryPoints: + web: + address: ":80" + + websecure: + address: ":443" + + certificatesResolvers: + myresolver: + tailscale: {} + ``` + + ```toml tab="File (TOML)" + [entryPoints] + [entryPoints.web] + address = ":80" + + [entryPoints.websecure] + address = ":443" + + [certificatesResolvers.myresolver.tailscale] + ``` + + ```bash tab="CLI" + --entrypoints.web.address=:80 + --entrypoints.websecure.address=:443 + # ... + --certificatesresolvers.myresolver.tailscale=true + ``` + +!!! example "Domain from Router's Rule Example" + + ```yaml tab="Docker" + ## Dynamic configuration + labels: + - traefik.http.routers.blog.rule=Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`) + - traefik.http.routers.blog.tls.certresolver=myresolver + ``` + + ```yaml tab="Docker (Swarm)" + ## Dynamic configuration + deploy: + labels: + - traefik.http.routers.blog.rule=Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`) + - traefik.http.routers.blog.tls.certresolver=myresolver + ``` + + ```yaml tab="Kubernetes" + apiVersion: traefik.containo.us/v1alpha1 + kind: IngressRoute + metadata: + name: blogtls + spec: + entryPoints: + - websecure + routes: + - match: Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`) + kind: Rule + services: + - name: blog + port: 8080 + tls: + certResolver: myresolver + ``` + + ```json tab="Marathon" + labels: { + "traefik.http.routers.blog.rule": "Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`)", + "traefik.http.routers.blog.tls.certresolver": "myresolver", + } + ``` + + ```yaml tab="Rancher" + ## Dynamic configuration + labels: + - traefik.http.routers.blog.rule=Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`) + - traefik.http.routers.blog.tls.certresolver=myresolver + ``` + + ```yaml tab="File (YAML)" + ## Dynamic configuration + http: + routers: + blog: + rule: "Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`)" + tls: + certResolver: myresolver + ``` + + ```toml tab="File (TOML)" + ## Dynamic configuration + [http.routers] + [http.routers.blog] + rule = "Host(`monitoring.yak-bebop.ts.net`) && Path(`/metrics`)" + [http.routers.blog.tls] + certResolver = "myresolver" + ``` + +!!! example "Domain from Router's tls.domain Example" + + ```yaml tab="Docker" + ## Dynamic configuration + labels: + - traefik.http.routers.blog.rule=Path(`/metrics`) + - traefik.http.routers.blog.tls.certresolver=myresolver + - traefik.http.routers.blog.tls.domains[0].main=monitoring.yak-bebop.ts.net + ``` + + ```yaml tab="Docker (Swarm)" + ## Dynamic configuration + deploy: + labels: + - traefik.http.routers.blog.rule=Path(`/metrics`) + - traefik.http.routers.blog.tls.certresolver=myresolver + - traefik.http.routers.blog.tls.domains[0].main=monitoring.yak-bebop.ts.net + ``` + + ```yaml tab="Kubernetes" + apiVersion: traefik.containo.us/v1alpha1 + kind: IngressRoute + metadata: + name: blogtls + spec: + entryPoints: + - websecure + routes: + - match: Path(`/metrics`) + kind: Rule + services: + - name: blog + port: 8080 + tls: + certResolver: myresolver + domains: + - main: monitoring.yak-bebop.ts.net + ``` + + ```json tab="Marathon" + labels: { + "traefik.http.routers.blog.rule": "Path(`/metrics`)", + "traefik.http.routers.blog.tls.certresolver": "myresolver", + "traefik.http.routers.blog.tls.domains[0].main": "monitoring.yak-bebop.ts.net", + } + ``` + + ```yaml tab="Rancher" + ## Dynamic configuration + labels: + - traefik.http.routers.blog.rule=Path(`/metrics`) + - traefik.http.routers.blog.tls.certresolver=myresolver + - traefik.http.routers.blog.tls.domains[0].main=monitoring.yak-bebop.ts.net + ``` + + ```yaml tab="File (YAML)" + ## Dynamic configuration + http: + routers: + blog: + rule: "Path(`/metrics`)" + tls: + certResolver: myresolver + domains: + - main: "monitoring.yak-bebop.ts.net" + ``` + + ```toml tab="File (TOML)" + ## Dynamic configuration + [http.routers] + [http.routers.blog] + rule = "Path(`/metrics`)" + [http.routers.blog.tls] + certResolver = "myresolver" + [[http.routers.blog.tls.domains]] + main = "monitoring.yak-bebop.ts.net" + ``` + +## Automatic Renewals + +Traefik automatically tracks the expiry date of each Tailscale certificate it fetches, +and starts to renew a certificate 14 days before its expiry to match Tailscale daemon renew policy. diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index ef9f2a125..2165b7200 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -99,6 +99,9 @@ Storage to use. (Default: ```acme.json```) `--certificatesresolvers..acme.tlschallenge`: Activate TLS-ALPN-01 Challenge. (Default: ```true```) +`--certificatesresolvers..tailscale`: +Enables Tailscale certificate resolution. (Default: ```true```) + `--entrypoints.`: Entry points definition. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 18c54f58e..e0cd77379 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -99,6 +99,9 @@ Storage to use. (Default: ```acme.json```) `TRAEFIK_CERTIFICATESRESOLVERS__ACME_TLSCHALLENGE`: Activate TLS-ALPN-01 Challenge. (Default: ```true```) +`TRAEFIK_CERTIFICATESRESOLVERS__TAILSCALE`: +Enables Tailscale certificate resolution. (Default: ```true```) + `TRAEFIK_ENTRYPOINTS_`: Entry points definition. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 20af99cc3..4fff202b2 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -420,25 +420,7 @@ [certificatesResolvers.CertificateResolver0.acme.httpChallenge] entryPoint = "foobar" [certificatesResolvers.CertificateResolver0.acme.tlsChallenge] - [certificatesResolvers.CertificateResolver1] - [certificatesResolvers.CertificateResolver1.acme] - email = "foobar" - caServer = "foobar" - preferredChain = "foobar" - storage = "foobar" - keyType = "foobar" - certificatesDuration = 42 - [certificatesResolvers.CertificateResolver1.acme.eab] - kid = "foobar" - hmacEncoded = "foobar" - [certificatesResolvers.CertificateResolver1.acme.dnsChallenge] - provider = "foobar" - delayBeforeCheck = "42s" - resolvers = ["foobar", "foobar"] - disablePropagationCheck = true - [certificatesResolvers.CertificateResolver1.acme.httpChallenge] - entryPoint = "foobar" - [certificatesResolvers.CertificateResolver1.acme.tlsChallenge] + [certificatesResolvers.CertificateResolver1.tailscale] [hub] [hub.tls] diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index ba5c35cb9..38b0e2637 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -447,26 +447,7 @@ certificatesResolvers: entryPoint: foobar tlsChallenge: {} CertificateResolver1: - acme: - email: foobar - caServer: foobar - certificatesDuration: 42 - preferredChain: foobar - storage: foobar - keyType: foobar - eab: - kid: foobar - hmacEncoded: foobar - dnsChallenge: - provider: foobar - delayBeforeCheck: 42s - resolvers: - - foobar - - foobar - disablePropagationCheck: true - httpChallenge: - entryPoint: foobar - tlsChallenge: {} + tailscale: {} hub: tls: insecure: true diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 87ad026fc..218154bc3 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -109,6 +109,7 @@ nav: - 'Overview': 'https/overview.md' - 'TLS': 'https/tls.md' - 'Let''s Encrypt': 'https/acme.md' + - 'Tailscale': 'https/tailscale.md' - 'Middlewares': - 'Overview': 'middlewares/overview.md' - 'HTTP': diff --git a/go.mod b/go.mod index 0ab1c3e42..a93fe6622 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.8.0 github.com/stvp/go-udp-testing v0.0.0-20191102171040-06b61409b154 + github.com/tailscale/tscert v0.0.0-20220316030059-54bbcb9f74e2 github.com/traefik/paerser v0.1.9 github.com/traefik/yaegi v0.14.2 github.com/uber/jaeger-client-go v2.30.0+incompatible @@ -245,6 +246,7 @@ require ( github.com/miekg/pkcs11 v1.0.3 // indirect github.com/mimuret/golang-iij-dpf v0.7.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/reflectwalk v1.0.1 // indirect github.com/moby/buildkit v0.8.2-0.20210401015549-df49b648c8bf // indirect diff --git a/go.sum b/go.sum index 4a6995368..ce099d7b1 100644 --- a/go.sum +++ b/go.sum @@ -1414,6 +1414,8 @@ github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFW github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.14.0/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= @@ -1878,6 +1880,8 @@ github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/tailscale/tscert v0.0.0-20220316030059-54bbcb9f74e2 h1:xwMw7LFhV9dbvot9A7NLClP9udqbjrQlIwWMH8e7uiQ= +github.com/tailscale/tscert v0.0.0-20220316030059-54bbcb9f74e2/go.mod h1:hL4gB6APAasMR2NNi/JHzqKkxW3EPQlFgLEq9PMi2t0= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.287 h1:ohsyW4WffPdd2JLPio2Sd0qGr93hzkawAt9vWdCFLgY= diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 03b6eae45..4d803b2ad 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -88,7 +88,8 @@ type Configuration struct { // CertificateResolver contains the configuration for the different types of certificates resolver. type CertificateResolver struct { - ACME *acmeprovider.Configuration `description:"Enable ACME (Let's Encrypt): automatic SSL." json:"acme,omitempty" toml:"acme,omitempty" yaml:"acme,omitempty" export:"true"` + ACME *acmeprovider.Configuration `description:"Enables ACME (Let's Encrypt) automatic SSL." json:"acme,omitempty" toml:"acme,omitempty" yaml:"acme,omitempty" export:"true"` + Tailscale *struct{} `description:"Enables Tailscale certificate resolution." json:"tailscale,omitempty" toml:"tailscale,omitempty" yaml:"tailscale,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` } // Global holds the global configuration. @@ -311,6 +312,10 @@ func (c *Configuration) initACMEProvider() { func (c *Configuration) ValidateConfiguration() error { var acmeEmail string for name, resolver := range c.CertificatesResolvers { + if resolver.ACME != nil && resolver.Tailscale != nil { + return fmt.Errorf("unable to initialize certificates resolver %q, as ACME and Tailscale providers are mutually exclusive", name) + } + if resolver.ACME == nil { continue } @@ -320,7 +325,7 @@ func (c *Configuration) ValidateConfiguration() error { } if acmeEmail != "" && resolver.ACME.Email != acmeEmail { - return fmt.Errorf("unable to initialize certificates resolver %q, all the acme resolvers must use the same email", name) + return fmt.Errorf("unable to initialize certificates resolver %q, as all ACME resolvers must use the same email", name) } acmeEmail = resolver.ACME.Email } diff --git a/pkg/provider/tailscale/provider.go b/pkg/provider/tailscale/provider.go new file mode 100644 index 000000000..6c7c99c9c --- /dev/null +++ b/pkg/provider/tailscale/provider.go @@ -0,0 +1,366 @@ +package tailscale + +import ( + "context" + "crypto/tls" + "crypto/x509" + "sort" + "strings" + "sync" + "time" + + "github.com/tailscale/tscert" + "github.com/traefik/traefik/v2/pkg/config/dynamic" + "github.com/traefik/traefik/v2/pkg/log" + "github.com/traefik/traefik/v2/pkg/muxer/http" + "github.com/traefik/traefik/v2/pkg/muxer/tcp" + "github.com/traefik/traefik/v2/pkg/safe" + traefiktls "github.com/traefik/traefik/v2/pkg/tls" +) + +// Provider is the Tailscale certificates provider implementation. It receives +// configuration updates (e.g. new router, with new domain) from Traefik core, +// fetches the corresponding TLS certificates from the Tailscale daemon, and +// sends back to Traefik core a configuration updated with the certificates. +type Provider struct { + ResolverName string + + dynConfigs chan dynamic.Configuration // updates from Traefik core + dynMessages chan<- dynamic.Message // update to Traefik core + + certByDomainMu sync.RWMutex + certByDomain map[string]traefiktls.Certificate +} + +// ThrottleDuration implements the aggregator.throttled interface, in order to +// ensure that this provider is unthrottled. +func (p *Provider) ThrottleDuration() time.Duration { + return 0 +} + +// Init implements the provider.Provider interface. +func (p *Provider) Init() error { + p.dynConfigs = make(chan dynamic.Configuration) + p.certByDomain = make(map[string]traefiktls.Certificate) + + return nil +} + +// HandleConfigUpdate hands out a configuration update to the provider. +func (p *Provider) HandleConfigUpdate(cfg dynamic.Configuration) { + p.dynConfigs <- cfg +} + +// Provide starts the provider, which will henceforth send configuration +// updates on dynMessages. +func (p *Provider) Provide(dynMessages chan<- dynamic.Message, pool *safe.Pool) error { + p.dynMessages = dynMessages + + fields := log.Str(log.ProviderName, p.ResolverName+".tailscale") + + pool.GoCtx(func(ctx context.Context) { + p.watchDomains(log.With(ctx, fields)) + }) + + pool.GoCtx(func(ctx context.Context) { + p.renewCertificates(log.With(ctx, fields)) + }) + + return nil +} + +// watchDomains watches for Tailscale domain certificates that should be +// fetched from the Tailscale daemon. +func (p *Provider) watchDomains(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + + case cfg := <-p.dynConfigs: + domains := p.findDomains(ctx, cfg) + newDomains := p.findNewDomains(domains) + purged := p.purgeUnusedCerts(domains) + + if len(newDomains) == 0 && !purged { + continue + } + + // TODO: what should we do if the fetched certificate is going to expire before the next refresh tick? + p.fetchCerts(ctx, newDomains) + p.sendDynamicConfig() + } + } +} + +// renewCertificates routinely renews previously resolved Tailscale +// certificates before they expire. +func (p *Provider) renewCertificates(ctx context.Context) { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + p.certByDomainMu.RLock() + var domainsToRenew []string + for domain, cert := range p.certByDomain { + tlsCert, err := cert.GetCertificateFromBytes() + if err != nil { + log.FromContext(ctx). + WithError(err). + Errorf("Unable to get certificate for domain %s", domain) + continue + } + + // Tailscale tries to renew certificates 14 days before its expiration date. + // See https://github.com/tailscale/tailscale/blob/d9efbd97cbf369151e31453749f6692df7413709/ipn/localapi/cert.go#L116 + if isValidCert(tlsCert, domain, time.Now().AddDate(0, 0, 14)) { + continue + } + + domainsToRenew = append(domainsToRenew, domain) + } + p.certByDomainMu.RUnlock() + + if len(domainsToRenew) == 0 { + continue + } + + p.fetchCerts(ctx, domainsToRenew) + p.sendDynamicConfig() + } + } +} + +// findDomains goes through the given dynamic.Configuration and returns all +// Tailscale-specific domains found. +func (p *Provider) findDomains(ctx context.Context, cfg dynamic.Configuration) []string { + logger := log.FromContext(ctx) + + var domains []string + + if cfg.HTTP != nil { + for _, router := range cfg.HTTP.Routers { + if router.TLS == nil || router.TLS.CertResolver != p.ResolverName { + continue + } + + // As a domain list is explicitly defined we are only using the + // configured domains. Only the Main domain is considered as + // Tailscale domain certificate does not support multiple SANs. + if len(router.TLS.Domains) > 0 { + for _, domain := range router.TLS.Domains { + domains = append(domains, domain.Main) + } + + continue + } + + parsedDomains, err := http.ParseDomains(router.Rule) + if err != nil { + logger.Errorf("Unable to parse HTTP router domains: %v", err) + continue + } + + domains = append(domains, parsedDomains...) + } + } + + if cfg.TCP != nil { + for _, router := range cfg.TCP.Routers { + if router.TLS == nil || router.TLS.CertResolver != p.ResolverName { + continue + } + + // As a domain list is explicitly defined we are only using the + // configured domains. Only the Main domain is considered as + // Tailscale domain certificate does not support multiple SANs. + if len(router.TLS.Domains) > 0 { + for _, domain := range router.TLS.Domains { + domains = append(domains, domain.Main) + } + + continue + } + + parsedDomains, err := tcp.ParseHostSNI(router.Rule) + if err != nil { + logger.Errorf("Unable to parse TCP router domains: %v", err) + continue + } + + domains = append(domains, parsedDomains...) + } + } + + return sanitizeDomains(ctx, domains) +} + +// findNewDomains returns the domains that have not already been fetched from +// the Tailscale daemon. +func (p *Provider) findNewDomains(domains []string) []string { + p.certByDomainMu.RLock() + defer p.certByDomainMu.RUnlock() + + var newDomains []string + for _, domain := range domains { + if _, ok := p.certByDomain[domain]; ok { + continue + } + + newDomains = append(newDomains, domain) + } + + return newDomains +} + +// purgeUnusedCerts purges the certByDomain map by removing unused certificates +// and returns whether some certificates have been removed. +func (p *Provider) purgeUnusedCerts(domains []string) bool { + p.certByDomainMu.Lock() + defer p.certByDomainMu.Unlock() + + newCertByDomain := make(map[string]traefiktls.Certificate) + for _, domain := range domains { + if cert, ok := p.certByDomain[domain]; ok { + newCertByDomain[domain] = cert + } + } + + purged := len(p.certByDomain) > len(newCertByDomain) + + p.certByDomain = newCertByDomain + + return purged +} + +// fetchCerts fetches the certificates for the provided domains from the +// Tailscale daemon. +func (p *Provider) fetchCerts(ctx context.Context, domains []string) { + logger := log.FromContext(ctx) + + for _, domain := range domains { + cert, key, err := tscert.CertPair(ctx, domain) + if err != nil { + logger.WithError(err).Errorf("Unable to fetch certificate for domain %q", domain) + continue + } + + logger.Debugf("Fetched certificate for domain %q", domain) + + p.certByDomainMu.Lock() + p.certByDomain[domain] = traefiktls.Certificate{ + CertFile: traefiktls.FileOrContent(cert), + KeyFile: traefiktls.FileOrContent(key), + } + p.certByDomainMu.Unlock() + } +} + +// sendDynamicConfig sends a dynamic.Message with the dynamic.Configuration +// containing the newly generated (or renewed) Tailscale certs. +func (p *Provider) sendDynamicConfig() { + p.certByDomainMu.RLock() + defer p.certByDomainMu.RUnlock() + + // TODO: we always send back to traefik core the set of certificates + // sorted, to make sure that two identical sets, that would be sorted + // differently, do not trigger another configuration update because of the + // mismatch. But in reality we should not end up sending a certificates + // update if there was no new certs to generate or renew in the first + // place, so this scenario should never happen, and the sorting might + // actually not be needed. + var sortedDomains []string + for domain := range p.certByDomain { + sortedDomains = append(sortedDomains, domain) + } + sort.Strings(sortedDomains) + + var certs []*traefiktls.CertAndStores + for _, domain := range sortedDomains { + // Only the default store is supported. + certs = append(certs, &traefiktls.CertAndStores{ + Stores: []string{traefiktls.DefaultTLSStoreName}, + Certificate: p.certByDomain[domain], + }) + } + + p.dynMessages <- dynamic.Message{ + ProviderName: p.ResolverName + ".tailscale", + Configuration: &dynamic.Configuration{ + TLS: &dynamic.TLSConfiguration{Certificates: certs}, + }, + } +} + +// sanitizeDomains removes duplicated and invalid Tailscale subdomains, from +// the provided list. +func sanitizeDomains(ctx context.Context, domains []string) []string { + logger := log.FromContext(ctx) + + seen := map[string]struct{}{} + + var sanitizedDomains []string + for _, domain := range domains { + if _, ok := seen[domain]; ok { + continue + } + + if !isTailscaleDomain(domain) { + logger.Errorf("Domain %s is not a valid Tailscale domain", domain) + continue + } + + sanitizedDomains = append(sanitizedDomains, domain) + seen[domain] = struct{}{} + } + return sanitizedDomains +} + +// isTailscaleDomain returns whether the given domain is a valid Tailscale +// domain. A valid Tailscale domain has the following form: +// machine-name.domains-alias.ts.net. +func isTailscaleDomain(domain string) bool { + // TODO: extra check, against the actual list of allowed domains names, + // provided by the Tailscale daemon status? + labels := strings.Split(domain, ".") + + return len(labels) == 4 && labels[2] == "ts" && labels[3] == "net" +} + +// isValidCert returns whether the given tls.Certificate is valid for the given +// domain at the given time. +func isValidCert(cert tls.Certificate, domain string, now time.Time) bool { + var leaf *x509.Certificate + + intermediates := x509.NewCertPool() + for i, raw := range cert.Certificate { + der, err := x509.ParseCertificate(raw) + if err != nil { + return false + } + + if i == 0 { + leaf = der + continue + } + + intermediates.AddCert(der) + } + + if leaf == nil { + return false + } + + _, err := leaf.Verify(x509.VerifyOptions{ + DNSName: domain, + Intermediates: intermediates, + CurrentTime: now, + }) + + return err == nil +} diff --git a/pkg/provider/tailscale/provider_test.go b/pkg/provider/tailscale/provider_test.go new file mode 100644 index 000000000..6a7ae0d6f --- /dev/null +++ b/pkg/provider/tailscale/provider_test.go @@ -0,0 +1,279 @@ +package tailscale + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/traefik/traefik/v2/pkg/config/dynamic" + traefiktls "github.com/traefik/traefik/v2/pkg/tls" + "github.com/traefik/traefik/v2/pkg/types" +) + +func TestProvider_findDomains(t *testing.T) { + testCases := []struct { + desc string + config dynamic.Configuration + want []string + }{ + { + desc: "ignore domain with non-matching resolver", + config: dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "foo": { + Rule: "Host(`machine.http.ts.net`)", + TLS: &dynamic.RouterTLSConfig{CertResolver: "bar"}, + }, + }, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{ + "foo": { + Rule: "HostSNI(`machine.tcp.ts.net`)", + TLS: &dynamic.RouterTCPTLSConfig{CertResolver: "bar"}, + }, + }, + }, + }, + }, + { + desc: "sanitize domains", + config: dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "dup": { + Rule: "Host(`machine.http.ts.net`)", + TLS: &dynamic.RouterTLSConfig{CertResolver: "foo"}, + }, + "malformed": { + Rule: "Host(`machine.http.ts.foo`)", + TLS: &dynamic.RouterTLSConfig{CertResolver: "foo"}, + }, + }, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{ + "dup": { + Rule: "HostSNI(`machine.http.ts.net`)", + TLS: &dynamic.RouterTCPTLSConfig{CertResolver: "foo"}, + }, + "malformed": { + Rule: "HostSNI(`machine.tcp.ts.foo`)", + TLS: &dynamic.RouterTCPTLSConfig{CertResolver: "foo"}, + }, + }, + }, + }, + want: []string{"machine.http.ts.net"}, + }, + { + desc: "domains from HTTP and TCP router rule", + config: dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "foo": { + Rule: "Host(`machine.http.ts.net`)", + TLS: &dynamic.RouterTLSConfig{CertResolver: "foo"}, + }, + }, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{ + "foo": { + Rule: "HostSNI(`machine.tcp.ts.net`)", + TLS: &dynamic.RouterTCPTLSConfig{CertResolver: "foo"}, + }, + }, + }, + }, + want: []string{"machine.http.ts.net", "machine.tcp.ts.net"}, + }, + { + desc: "domains from HTTP and TCP TLS configuration", + config: dynamic.Configuration{ + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "foo": { + Rule: "Host(`machine.http.ts.net`)", + TLS: &dynamic.RouterTLSConfig{ + Domains: []types.Domain{{Main: "main.http.ts.net"}}, + CertResolver: "foo", + }, + }, + }, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{ + "foo": { + Rule: "HostSNI(`machine.tcp.ts.net`)", + TLS: &dynamic.RouterTCPTLSConfig{ + Domains: []types.Domain{{Main: "main.tcp.ts.net"}}, + CertResolver: "foo", + }, + }, + }, + }, + }, + want: []string{"main.http.ts.net", "main.tcp.ts.net"}, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + p := Provider{ResolverName: "foo"} + + got := p.findDomains(context.TODO(), test.config) + assert.Equal(t, test.want, got) + }) + } +} + +func TestProvider_findNewDomains(t *testing.T) { + p := Provider{ + ResolverName: "foo", + certByDomain: map[string]traefiktls.Certificate{ + "foo.com": {}, + }, + } + + got := p.findNewDomains([]string{"foo.com", "bar.com"}) + assert.Equal(t, []string{"bar.com"}, got) +} + +func TestProvider_purgeUnusedCerts(t *testing.T) { + p := Provider{ + ResolverName: "foo", + certByDomain: map[string]traefiktls.Certificate{ + "foo.com": {}, + "bar.com": {}, + }, + } + + got := p.purgeUnusedCerts([]string{"foo.com"}) + assert.True(t, got) + + assert.Len(t, p.certByDomain, 1) + assert.Contains(t, p.certByDomain, "foo.com") +} + +func TestProvider_sendDynamicConfig(t *testing.T) { + testCases := []struct { + desc string + certByDomain map[string]traefiktls.Certificate + want []*traefiktls.CertAndStores + }{ + { + desc: "without certificates", + }, + { + desc: "with certificates", + certByDomain: map[string]traefiktls.Certificate{ + "foo.com": {CertFile: "foo.crt", KeyFile: "foo.key"}, + "bar.com": {CertFile: "bar.crt", KeyFile: "bar.key"}, + }, + want: []*traefiktls.CertAndStores{ + { + Certificate: traefiktls.Certificate{CertFile: "bar.crt", KeyFile: "bar.key"}, + Stores: []string{traefiktls.DefaultTLSStoreName}, + }, + { + Certificate: traefiktls.Certificate{CertFile: "foo.crt", KeyFile: "foo.key"}, + Stores: []string{traefiktls.DefaultTLSStoreName}, + }, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + msgCh := make(chan dynamic.Message, 1) + p := Provider{ + ResolverName: "foo", + dynMessages: msgCh, + certByDomain: test.certByDomain, + } + + p.sendDynamicConfig() + + got := <-msgCh + + assert.Equal(t, "foo.tailscale", got.ProviderName) + assert.NotNil(t, got.Configuration) + assert.Equal(t, &dynamic.TLSConfiguration{Certificates: test.want}, got.Configuration.TLS) + }) + } +} + +func Test_sanitizeDomains(t *testing.T) { + testCases := []struct { + desc string + domains []string + want []string + }{ + { + desc: "duplicate domains", + domains: []string{"foo.domain.ts.net", "foo.domain.ts.net"}, + want: []string{"foo.domain.ts.net"}, + }, + { + desc: "not a Tailscale domain", + domains: []string{"foo.domain.ts.com"}, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + got := sanitizeDomains(context.TODO(), test.domains) + assert.Equal(t, test.want, got) + }) + } +} + +func Test_isTailscaleDomain(t *testing.T) { + testCases := []struct { + desc string + domain string + want bool + }{ + { + desc: "valid domains", + domain: "machine.domains.ts.net", + want: true, + }, + { + desc: "bad suffix", + domain: "machine.domains.foo.net", + want: false, + }, + { + desc: "too much labels", + domain: "foo.machine.domains.ts.net", + want: false, + }, + { + desc: "not enough labels", + domain: "domains.ts.net", + want: false, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + got := isTailscaleDomain(test.domain) + assert.Equal(t, test.want, got) + }) + } +} diff --git a/pkg/tls/certificate.go b/pkg/tls/certificate.go index fca4a2586..25559a22e 100644 --- a/pkg/tls/certificate.go +++ b/pkg/tls/certificate.go @@ -162,21 +162,32 @@ func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certifi return err } -// GetCertificate retrieves Certificate as tls.Certificate. +// GetCertificate returns a tls.Certificate matching the configured CertFile and KeyFile. func (c *Certificate) GetCertificate() (tls.Certificate, error) { certContent, err := c.CertFile.Read() if err != nil { - return tls.Certificate{}, fmt.Errorf("unable to read CertFile : %w", err) + return tls.Certificate{}, fmt.Errorf("unable to read CertFile: %w", err) } keyContent, err := c.KeyFile.Read() if err != nil { - return tls.Certificate{}, fmt.Errorf("unable to read KeyFile : %w", err) + return tls.Certificate{}, fmt.Errorf("unable to read KeyFile: %w", err) } cert, err := tls.X509KeyPair(certContent, keyContent) if err != nil { - return tls.Certificate{}, fmt.Errorf("unable to generate TLS certificate : %w", err) + return tls.Certificate{}, fmt.Errorf("unable to parse TLS certificate: %w", err) + } + + return cert, nil +} + +// GetCertificateFromBytes returns a tls.Certificate matching the configured CertFile and KeyFile. +// It assumes that the configured CertFile and KeyFile are of byte type. +func (c *Certificate) GetCertificateFromBytes() (tls.Certificate, error) { + cert, err := tls.X509KeyPair([]byte(c.CertFile), []byte(c.KeyFile)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("unable to parse TLS certificate: %w", err) } return cert, nil