Add Tailscale certificate resolver
Co-authored-by: Mathieu Lonjaret <mathieu.lonjaret@gmail.com>
This commit is contained in:
parent
033fccccc7
commit
38d7011487
13 changed files with 957 additions and 48 deletions
|
@ -35,6 +35,7 @@ import (
|
||||||
"github.com/traefik/traefik/v2/pkg/provider/acme"
|
"github.com/traefik/traefik/v2/pkg/provider/acme"
|
||||||
"github.com/traefik/traefik/v2/pkg/provider/aggregator"
|
"github.com/traefik/traefik/v2/pkg/provider/aggregator"
|
||||||
"github.com/traefik/traefik/v2/pkg/provider/hub"
|
"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/provider/traefik"
|
||||||
"github.com/traefik/traefik/v2/pkg/safe"
|
"github.com/traefik/traefik/v2/pkg/safe"
|
||||||
"github.com/traefik/traefik/v2/pkg/server"
|
"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)
|
acmeProviders := initACMEProvider(staticConfiguration, &providerAggregator, tlsManager, httpChallengeProvider, tlsChallengeProvider)
|
||||||
|
|
||||||
|
// Tailscale
|
||||||
|
|
||||||
|
tsProviders := initTailscaleProviders(staticConfiguration, &providerAggregator)
|
||||||
|
|
||||||
// Entrypoints
|
// Entrypoints
|
||||||
|
|
||||||
serverEntryPointsTCP, err := server.NewTCPEntryPoints(staticConfiguration.EntryPoints, staticConfiguration.HostResolver)
|
serverEntryPointsTCP, err := server.NewTCPEntryPoints(staticConfiguration.EntryPoints, staticConfiguration.HostResolver)
|
||||||
|
@ -313,13 +318,22 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err
|
||||||
// TLS challenge
|
// TLS challenge
|
||||||
watcher.AddListener(tlsChallengeProvider.ListenConfiguration)
|
watcher.AddListener(tlsChallengeProvider.ListenConfiguration)
|
||||||
|
|
||||||
// ACME
|
// Certificate Resolvers
|
||||||
|
|
||||||
resolverNames := map[string]struct{}{}
|
resolverNames := map[string]struct{}{}
|
||||||
|
|
||||||
|
// ACME
|
||||||
for _, p := range acmeProviders {
|
for _, p := range acmeProviders {
|
||||||
resolverNames[p.ResolverName] = struct{}{}
|
resolverNames[p.ResolverName] = struct{}{}
|
||||||
watcher.AddListener(p.ListenConfiguration)
|
watcher.AddListener(p.ListenConfiguration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tailscale
|
||||||
|
for _, p := range tsProviders {
|
||||||
|
resolverNames[p.ResolverName] = struct{}{}
|
||||||
|
watcher.AddListener(p.HandleConfigUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
// Certificate resolver logs
|
// Certificate resolver logs
|
||||||
watcher.AddListener(func(config dynamic.Configuration) {
|
watcher.AddListener(func(config dynamic.Configuration) {
|
||||||
for rtName, rt := range config.HTTP.Routers {
|
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.
|
// "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.
|
// 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") {
|
(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 {
|
func initACMEProvider(c *static.Configuration, providerAggregator *aggregator.ProviderAggregator, tlsManager *traefiktls.Manager, httpChallengeProvider, tlsChallengeProvider challenge.Provider) []*acme.Provider {
|
||||||
localStores := map[string]*acme.LocalStore{}
|
localStores := map[string]*acme.LocalStore{}
|
||||||
|
|
||||||
|
@ -421,6 +435,27 @@ func initACMEProvider(c *static.Configuration, providerAggregator *aggregator.Pr
|
||||||
return resolvers
|
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 {
|
func registerMetricClients(metricsConfig *types.Metrics) []metrics.Registry {
|
||||||
if metricsConfig == nil {
|
if metricsConfig == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
237
docs/content/https/tailscale.md
Normal file
237
docs/content/https/tailscale.md
Normal file
|
@ -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.
|
|
@ -99,6 +99,9 @@ Storage to use. (Default: ```acme.json```)
|
||||||
`--certificatesresolvers.<name>.acme.tlschallenge`:
|
`--certificatesresolvers.<name>.acme.tlschallenge`:
|
||||||
Activate TLS-ALPN-01 Challenge. (Default: ```true```)
|
Activate TLS-ALPN-01 Challenge. (Default: ```true```)
|
||||||
|
|
||||||
|
`--certificatesresolvers.<name>.tailscale`:
|
||||||
|
Enables Tailscale certificate resolution. (Default: ```true```)
|
||||||
|
|
||||||
`--entrypoints.<name>`:
|
`--entrypoints.<name>`:
|
||||||
Entry points definition. (Default: ```false```)
|
Entry points definition. (Default: ```false```)
|
||||||
|
|
||||||
|
|
|
@ -99,6 +99,9 @@ Storage to use. (Default: ```acme.json```)
|
||||||
`TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_TLSCHALLENGE`:
|
`TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_TLSCHALLENGE`:
|
||||||
Activate TLS-ALPN-01 Challenge. (Default: ```true```)
|
Activate TLS-ALPN-01 Challenge. (Default: ```true```)
|
||||||
|
|
||||||
|
`TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_TAILSCALE`:
|
||||||
|
Enables Tailscale certificate resolution. (Default: ```true```)
|
||||||
|
|
||||||
`TRAEFIK_ENTRYPOINTS_<NAME>`:
|
`TRAEFIK_ENTRYPOINTS_<NAME>`:
|
||||||
Entry points definition. (Default: ```false```)
|
Entry points definition. (Default: ```false```)
|
||||||
|
|
||||||
|
|
|
@ -420,25 +420,7 @@
|
||||||
[certificatesResolvers.CertificateResolver0.acme.httpChallenge]
|
[certificatesResolvers.CertificateResolver0.acme.httpChallenge]
|
||||||
entryPoint = "foobar"
|
entryPoint = "foobar"
|
||||||
[certificatesResolvers.CertificateResolver0.acme.tlsChallenge]
|
[certificatesResolvers.CertificateResolver0.acme.tlsChallenge]
|
||||||
[certificatesResolvers.CertificateResolver1]
|
[certificatesResolvers.CertificateResolver1.tailscale]
|
||||||
[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]
|
|
||||||
|
|
||||||
[hub]
|
[hub]
|
||||||
[hub.tls]
|
[hub.tls]
|
||||||
|
|
|
@ -447,26 +447,7 @@ certificatesResolvers:
|
||||||
entryPoint: foobar
|
entryPoint: foobar
|
||||||
tlsChallenge: {}
|
tlsChallenge: {}
|
||||||
CertificateResolver1:
|
CertificateResolver1:
|
||||||
acme:
|
tailscale: {}
|
||||||
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: {}
|
|
||||||
hub:
|
hub:
|
||||||
tls:
|
tls:
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
|
@ -109,6 +109,7 @@ nav:
|
||||||
- 'Overview': 'https/overview.md'
|
- 'Overview': 'https/overview.md'
|
||||||
- 'TLS': 'https/tls.md'
|
- 'TLS': 'https/tls.md'
|
||||||
- 'Let''s Encrypt': 'https/acme.md'
|
- 'Let''s Encrypt': 'https/acme.md'
|
||||||
|
- 'Tailscale': 'https/tailscale.md'
|
||||||
- 'Middlewares':
|
- 'Middlewares':
|
||||||
- 'Overview': 'middlewares/overview.md'
|
- 'Overview': 'middlewares/overview.md'
|
||||||
- 'HTTP':
|
- 'HTTP':
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -59,6 +59,7 @@ require (
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
github.com/stretchr/testify v1.8.0
|
github.com/stretchr/testify v1.8.0
|
||||||
github.com/stvp/go-udp-testing v0.0.0-20191102171040-06b61409b154
|
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/paerser v0.1.9
|
||||||
github.com/traefik/yaegi v0.14.2
|
github.com/traefik/yaegi v0.14.2
|
||||||
github.com/uber/jaeger-client-go v2.30.0+incompatible
|
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/miekg/pkcs11 v1.0.3 // indirect
|
||||||
github.com/mimuret/golang-iij-dpf v0.7.1 // indirect
|
github.com/mimuret/golang-iij-dpf v0.7.1 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // 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/go-testing-interface v1.14.1 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.1 // indirect
|
github.com/mitchellh/reflectwalk v1.0.1 // indirect
|
||||||
github.com/moby/buildkit v0.8.2-0.20210401015549-df49b648c8bf // indirect
|
github.com/moby/buildkit v0.8.2-0.20210401015549-df49b648c8bf // indirect
|
||||||
|
|
4
go.sum
4
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.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 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
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 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.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||||
github.com/mitchellh/go-testing-interface v1.14.0/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
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-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
|
||||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/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/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/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/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=
|
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.287 h1:ohsyW4WffPdd2JLPio2Sd0qGr93hzkawAt9vWdCFLgY=
|
||||||
|
|
|
@ -88,7 +88,8 @@ type Configuration struct {
|
||||||
|
|
||||||
// CertificateResolver contains the configuration for the different types of certificates resolver.
|
// CertificateResolver contains the configuration for the different types of certificates resolver.
|
||||||
type CertificateResolver struct {
|
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.
|
// Global holds the global configuration.
|
||||||
|
@ -311,6 +312,10 @@ func (c *Configuration) initACMEProvider() {
|
||||||
func (c *Configuration) ValidateConfiguration() error {
|
func (c *Configuration) ValidateConfiguration() error {
|
||||||
var acmeEmail string
|
var acmeEmail string
|
||||||
for name, resolver := range c.CertificatesResolvers {
|
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 {
|
if resolver.ACME == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -320,7 +325,7 @@ func (c *Configuration) ValidateConfiguration() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if acmeEmail != "" && resolver.ACME.Email != acmeEmail {
|
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
|
acmeEmail = resolver.ACME.Email
|
||||||
}
|
}
|
||||||
|
|
366
pkg/provider/tailscale/provider.go
Normal file
366
pkg/provider/tailscale/provider.go
Normal file
|
@ -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
|
||||||
|
}
|
279
pkg/provider/tailscale/provider_test.go
Normal file
279
pkg/provider/tailscale/provider_test.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -162,7 +162,7 @@ func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certifi
|
||||||
return err
|
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) {
|
func (c *Certificate) GetCertificate() (tls.Certificate, error) {
|
||||||
certContent, err := c.CertFile.Read()
|
certContent, err := c.CertFile.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -176,7 +176,18 @@ func (c *Certificate) GetCertificate() (tls.Certificate, error) {
|
||||||
|
|
||||||
cert, err := tls.X509KeyPair(certContent, keyContent)
|
cert, err := tls.X509KeyPair(certContent, keyContent)
|
||||||
if err != nil {
|
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
|
return cert, nil
|
||||||
|
|
Loading…
Reference in a new issue