mirror of
https://github.com/tailscale/tailscale.git
synced 2025-03-28 12:02:23 +00:00

cmd/containerboot: manage HA Ingress TLS certs from containerboot When ran as HA Ingress node, containerboot now can determine whether it should manage TLS certs for the HA Ingress replicas and call the LocalAPI cert endpoint to ensure initial issuance and renewal of the shared TLS certs. Updates tailscale/corp#24795 Signed-off-by: Irbe Krumina <irbe@tailscale.com>
230 lines
5.9 KiB
Go
230 lines
5.9 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build linux
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
// TestEnsureCertLoops tests that the certManager correctly starts and stops
|
|
// update loops for certs when the serve config changes. It tracks goroutine
|
|
// count and uses that as a validator that the expected number of cert loops are
|
|
// running.
|
|
func TestEnsureCertLoops(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
initialConfig *ipn.ServeConfig
|
|
updatedConfig *ipn.ServeConfig
|
|
initialGoroutines int64 // after initial serve config is applied
|
|
updatedGoroutines int64 // after updated serve config is applied
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "empty_serve_config",
|
|
initialConfig: &ipn.ServeConfig{},
|
|
initialGoroutines: 0,
|
|
},
|
|
{
|
|
name: "nil_serve_config",
|
|
initialConfig: nil,
|
|
initialGoroutines: 0,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty_to_one_service",
|
|
initialConfig: &ipn.ServeConfig{},
|
|
updatedConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
initialGoroutines: 0,
|
|
updatedGoroutines: 1,
|
|
},
|
|
{
|
|
name: "single_service",
|
|
initialConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
initialGoroutines: 1,
|
|
},
|
|
{
|
|
name: "multiple_services",
|
|
initialConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
"svc:my-other-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-other-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
initialGoroutines: 2, // one loop per domain across all services
|
|
},
|
|
{
|
|
name: "ignore_non_https_ports",
|
|
initialConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
"my-app.tailnetxyz.ts.net:80": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
initialGoroutines: 1, // only one loop for the 443 endpoint
|
|
},
|
|
{
|
|
name: "remove_domain",
|
|
initialConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
"svc:my-other-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-other-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
updatedConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
initialGoroutines: 2, // initially two loops (one per service)
|
|
updatedGoroutines: 1, // one loop after removing service2
|
|
},
|
|
{
|
|
name: "add_domain",
|
|
initialConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
updatedConfig: &ipn.ServeConfig{
|
|
Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{
|
|
"svc:my-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
"svc:my-other-app": {
|
|
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
|
"my-other-app.tailnetxyz.ts.net:443": {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
initialGoroutines: 1,
|
|
updatedGoroutines: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
cm := &certManager{
|
|
lc: &fakeLocalClient{},
|
|
certLoops: make(map[string]context.CancelFunc),
|
|
}
|
|
|
|
allDone := make(chan bool, 1)
|
|
defer cm.tracker.AddDoneCallback(func() {
|
|
cm.mu.Lock()
|
|
defer cm.mu.Unlock()
|
|
if cm.tracker.RunningGoroutines() > 0 {
|
|
return
|
|
}
|
|
select {
|
|
case allDone <- true:
|
|
default:
|
|
}
|
|
})()
|
|
|
|
err := cm.ensureCertLoops(ctx, tt.initialConfig)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("ensureCertLoops() error = %v", err)
|
|
}
|
|
|
|
if got := cm.tracker.RunningGoroutines(); got != tt.initialGoroutines {
|
|
t.Errorf("after initial config: got %d running goroutines, want %d", got, tt.initialGoroutines)
|
|
}
|
|
|
|
if tt.updatedConfig != nil {
|
|
if err := cm.ensureCertLoops(ctx, tt.updatedConfig); err != nil {
|
|
t.Fatalf("ensureCertLoops() error on update = %v", err)
|
|
}
|
|
|
|
// Although starting goroutines and cancelling
|
|
// the context happens in the main goroutine, it
|
|
// the actual goroutine exit when a context is
|
|
// cancelled does not- so wait for a bit for the
|
|
// running goroutine count to reach the expected
|
|
// number.
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
if got := cm.tracker.RunningGoroutines(); got == tt.updatedGoroutines {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for goroutine count to reach %d, currently at %d",
|
|
tt.updatedGoroutines, cm.tracker.RunningGoroutines())
|
|
case <-time.After(10 * time.Millisecond):
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if tt.updatedGoroutines == 0 {
|
|
return // no goroutines to wait for
|
|
}
|
|
// cancel context to make goroutines exit
|
|
cancel()
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for goroutine to finish")
|
|
case <-allDone:
|
|
}
|
|
})
|
|
}
|
|
}
|