mirror of
https://github.com/k3s-io/k3s.git
synced 2024-06-07 19:41:36 +00:00
992e64993d
Allow bootstrapping with kubeadm bootstrap token strings or existing Kubelet certs. This allows agents to join the cluster using kubeadm bootstrap tokens, as created with the `k3s token create` command. When the token expires or is deleted, agents can successfully restart by authenticating with their kubelet certificate via node authentication. If the token is gone and the node is deleted from the cluster, node auth will fail and they will be prevented from rejoining the cluster until provided with a valid token. Servers still must be bootstrapped with the static cluster token, as they will need to know it to decrypt the bootstrap data. Signed-off-by: Brad Davidson <brad.davidson@rancher.com>
443 lines
14 KiB
Go
443 lines
14 KiB
Go
package clientaccess
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/k3s-io/k3s/pkg/bootstrap"
|
|
"github.com/k3s-io/k3s/pkg/daemons/config"
|
|
"github.com/rancher/dynamiclistener/cert"
|
|
"github.com/rancher/dynamiclistener/factory"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
var (
|
|
defaultUsername = "server"
|
|
defaultPassword = "token"
|
|
defaultToken = "abcdef.0123456789abcdef"
|
|
)
|
|
|
|
// Test_UnitTrustedCA confirms that tokens are validated when the server uses a cert (self-signed or otherwise)
|
|
// that is trusted by the OS CA bundle. This test must be run first, since it mucks with the system root certs.
|
|
func Test_UnitTrustedCA(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, false)
|
|
defer server.Close()
|
|
digest, _ := hashCA(getServerCA(server))
|
|
|
|
testInfo := &Info{
|
|
CACerts: getServerCA(server),
|
|
BaseURL: server.URL,
|
|
Username: defaultUsername,
|
|
Password: defaultPassword,
|
|
caHash: digest,
|
|
}
|
|
|
|
testCases := []struct {
|
|
token string
|
|
expected string
|
|
}{
|
|
{defaultPassword, ""},
|
|
{testInfo.String(), testInfo.Username},
|
|
}
|
|
|
|
// Point OS CA bundle at this test's CA cert to simulate a trusted CA cert.
|
|
// Note that this only works if the OS CA bundle has not yet been loaded in this process,
|
|
// as it is cached for the duration of the process lifetime.
|
|
// Ref: https://github.com/golang/go/issues/41888
|
|
path := t.TempDir() + "/ca.crt"
|
|
writeServerCA(server, path)
|
|
os.Setenv("SSL_CERT_FILE", path)
|
|
|
|
for _, testCase := range testCases {
|
|
info, err := ParseAndValidateToken(server.URL, testCase.token)
|
|
if assert.NoError(err, testCase) {
|
|
assert.Nil(info.CACerts, testCase)
|
|
assert.Equal(testCase.expected, info.Username, testCase.token)
|
|
}
|
|
|
|
info, err = ParseAndValidateToken(server.URL, testCase.token, WithUser("agent"))
|
|
if assert.NoError(err, testCase) {
|
|
assert.Nil(info.CACerts, testCase)
|
|
assert.Equal("agent", info.Username, testCase)
|
|
}
|
|
}
|
|
|
|
// Confirm that the cert is actually trusted by the OS CA bundle by making a request
|
|
// with empty cert pool
|
|
testInfo.CACerts = nil
|
|
res, err := testInfo.Get("/v1-k3s/server-bootstrap")
|
|
assert.NoError(err)
|
|
assert.NotEmpty(res)
|
|
}
|
|
|
|
// Test_UnitUntrustedCA confirms that tokens are validated when the server uses a self-signed cert
|
|
// that is NOT trusted by the OS CA bundle.
|
|
func Test_UnitUntrustedCA(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, false)
|
|
defer server.Close()
|
|
digest, _ := hashCA(getServerCA(server))
|
|
|
|
testInfo := &Info{
|
|
CACerts: getServerCA(server),
|
|
BaseURL: server.URL,
|
|
Username: defaultUsername,
|
|
Password: defaultPassword,
|
|
caHash: digest,
|
|
}
|
|
|
|
testCases := []struct {
|
|
token string
|
|
expected string
|
|
}{
|
|
{defaultPassword, ""},
|
|
{testInfo.String(), testInfo.Username},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
info, err := ParseAndValidateToken(server.URL, testCase.token)
|
|
if assert.NoError(err, testCase) {
|
|
assert.Equal(testInfo.CACerts, info.CACerts, testCase)
|
|
assert.Equal(testCase.expected, info.Username, testCase)
|
|
}
|
|
|
|
info, err = ParseAndValidateToken(server.URL, testCase.token, WithUser("agent"))
|
|
if assert.NoError(err, testCase) {
|
|
assert.Equal(testInfo.CACerts, info.CACerts, testCase)
|
|
assert.Equal("agent", info.Username, testCase)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test_UnitInvalidServers tests that invalid server URLs are properly rejected
|
|
func Test_UnitInvalidServers(t *testing.T) {
|
|
assert := assert.New(t)
|
|
testCases := []struct {
|
|
server string
|
|
token string
|
|
expected string
|
|
}{
|
|
{" https://localhost:6443", "token", "Invalid server url, failed to parse: https://localhost:6443: parse \" https://localhost:6443\": first path segment in URL cannot contain colon"},
|
|
{"http://localhost:6443", "token", "only https:// URLs are supported, invalid scheme: http://localhost:6443"},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
_, err := ParseAndValidateToken(testCase.server, testCase.token)
|
|
assert.EqualError(err, testCase.expected, testCase)
|
|
|
|
_, err = ParseAndValidateToken(testCase.server, testCase.token, WithUser(defaultUsername))
|
|
assert.EqualError(err, testCase.expected, testCase)
|
|
}
|
|
}
|
|
|
|
// Test_UnitValidTokens tests that valid tokens can be parsed, and give the expected result
|
|
func Test_UnitValidTokens(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, false)
|
|
defer server.Close()
|
|
digest, _ := hashCA(getServerCA(server))
|
|
|
|
testCases := []struct {
|
|
server string
|
|
token string
|
|
expectUsername string
|
|
expectPassword string
|
|
expectToken string
|
|
}{
|
|
{server.URL, defaultPassword, "", defaultPassword, ""},
|
|
{server.URL, defaultToken, "", "", defaultToken},
|
|
{server.URL, "K10" + digest + ":::" + defaultPassword, "", defaultPassword, ""},
|
|
{server.URL, "K10" + digest + "::" + defaultUsername + ":" + defaultPassword, defaultUsername, defaultPassword, ""},
|
|
{server.URL, "K10" + digest + "::" + defaultToken, "", "", defaultToken},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
info, err := ParseAndValidateToken(testCase.server, testCase.token)
|
|
assert.NoError(err)
|
|
assert.Equal(testCase.expectUsername, info.Username, testCase)
|
|
assert.Equal(testCase.expectPassword, info.Password, testCase)
|
|
assert.Equal(testCase.expectToken, info.Token(), testCase)
|
|
}
|
|
}
|
|
|
|
// Test_UnitInvalidTokens tests that tokens which are empty, invalid, or incorrect are properly rejected
|
|
func Test_UnitInvalidTokens(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, false)
|
|
defer server.Close()
|
|
digest, _ := hashCA(getServerCA(server))
|
|
|
|
testCases := []struct {
|
|
server string
|
|
token string
|
|
expected string
|
|
}{
|
|
{server.URL, "", "token must not be empty"},
|
|
{server.URL, "K10::", "invalid token format"},
|
|
{server.URL, "K10::x", "invalid token format"},
|
|
{server.URL, "K10::x:", "invalid token format"},
|
|
{server.URL, "K10XX::x:y", "invalid token CA hash length"},
|
|
{server.URL,
|
|
"K10XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX::x:y",
|
|
"token CA hash does not match the Cluster CA certificate hash: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX != " + digest},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
info, err := ParseAndValidateToken(testCase.server, testCase.token)
|
|
assert.EqualError(err, testCase.expected, testCase)
|
|
assert.Nil(info, testCase)
|
|
|
|
info, err = ParseAndValidateToken(testCase.server, testCase.token, WithUser(defaultUsername))
|
|
assert.EqualError(err, testCase.expected, testCase)
|
|
assert.Nil(info, testCase)
|
|
}
|
|
}
|
|
|
|
// Test_UnitInvalidCredentials tests that tokens which don't have valid credentials are rejected
|
|
func Test_UnitInvalidCredentials(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, false)
|
|
defer server.Close()
|
|
digest, _ := hashCA(getServerCA(server))
|
|
|
|
testInfo := &Info{
|
|
CACerts: getServerCA(server),
|
|
BaseURL: server.URL,
|
|
Username: "nobody",
|
|
Password: "invalid",
|
|
caHash: digest,
|
|
}
|
|
|
|
testCases := []string{
|
|
testInfo.Password,
|
|
testInfo.String(),
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
info, err := ParseAndValidateToken(server.URL, testCase)
|
|
assert.NoError(err, testCase)
|
|
if assert.NotNil(info) {
|
|
res, err := info.Get("/v1-k3s/server-bootstrap")
|
|
assert.Error(err, testCase)
|
|
assert.Empty(res, testCase)
|
|
}
|
|
|
|
info, err = ParseAndValidateToken(server.URL, testCase, WithUser(defaultUsername))
|
|
assert.NoError(err, testCase)
|
|
if assert.NotNil(info) {
|
|
res, err := info.Get("/v1-k3s/server-bootstrap")
|
|
assert.Error(err, testCase)
|
|
assert.Empty(res, testCase)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test_UnitWrongCert tests that errors are returned when the server's cert isn't issued by its CA
|
|
func Test_UnitWrongCert(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, true)
|
|
defer server.Close()
|
|
|
|
info, err := ParseAndValidateToken(server.URL, defaultPassword)
|
|
assert.Error(err)
|
|
assert.Nil(info)
|
|
|
|
info, err = ParseAndValidateToken(server.URL, defaultPassword, WithUser(defaultUsername))
|
|
assert.Error(err)
|
|
assert.Nil(info)
|
|
}
|
|
|
|
// Test_UnitConnectionFailures tests that connections are timed out properly
|
|
func Test_UnitConnectionFailures(t *testing.T) {
|
|
testDuration := (defaultClientTimeout * 2) + time.Second
|
|
assert := assert.New(t)
|
|
testCases := []struct {
|
|
server string
|
|
token string
|
|
}{
|
|
{"https://192.0.2.1:6443", "token"}, // RFC 5735 TEST-NET-1 for use in documentation and example code
|
|
{"https://localhost:1", "token"},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
startTime := time.Now()
|
|
info, err := ParseAndValidateToken(testCase.server, testCase.token)
|
|
assert.Error(err, testCase)
|
|
assert.Nil(info, testCase)
|
|
assert.WithinDuration(time.Now(), startTime, testDuration, testCase)
|
|
|
|
startTime = time.Now()
|
|
info, err = ParseAndValidateToken(testCase.server, testCase.token, WithUser(defaultUsername))
|
|
assert.Error(err, testCase)
|
|
assert.Nil(info, testCase)
|
|
assert.WithinDuration(startTime, time.Now(), testDuration, testCase)
|
|
}
|
|
}
|
|
|
|
// Test_UnitUserPass tests that usernames and passwords are parsed or not parsed from token strings
|
|
func Test_UnitUserPass(t *testing.T) {
|
|
assert := assert.New(t)
|
|
testCases := []struct {
|
|
token string
|
|
username string
|
|
password string
|
|
expect bool
|
|
}{
|
|
{"K10XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX::username:password", "username", "password", true},
|
|
{"password", "", "password", true},
|
|
{"K10X::x", "", "", false},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
username, password, ok := ParseUsernamePassword(testCase.token)
|
|
assert.Equal(testCase.expect, ok, testCase)
|
|
if ok {
|
|
assert.Equal(testCase.username, username, testCase)
|
|
assert.Equal(testCase.password, password, testCase)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test_UnitParseAndGet tests URL handling along some hard-to-reach code paths
|
|
func Test_UnitParseAndGet(t *testing.T) {
|
|
assert := assert.New(t)
|
|
server := newTLSServer(t, defaultUsername, defaultPassword, false)
|
|
defer server.Close()
|
|
|
|
testCases := []struct {
|
|
extraBasePre string
|
|
extraBasePost string
|
|
path string
|
|
parseFail bool
|
|
getFail bool
|
|
}{
|
|
{"/", "", "/cacerts", false, false},
|
|
{"/%2", "", "/cacerts", true, false},
|
|
{"", "", "/%2", false, true},
|
|
{"", "/%2", "/cacerts", false, true},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
info, err := ParseAndValidateToken(server.URL+testCase.extraBasePre, defaultPassword, WithUser(defaultUsername))
|
|
// Check for expected error when parsing server + token
|
|
if testCase.parseFail {
|
|
assert.Error(err, testCase)
|
|
} else if assert.NoError(err, testCase) {
|
|
info.BaseURL = server.URL + testCase.extraBasePost
|
|
_, err := info.Get(testCase.path)
|
|
// Check for expected error when making Get request
|
|
if testCase.getFail {
|
|
assert.Error(err, testCase)
|
|
} else {
|
|
assert.NoError(err, testCase)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// newTLSServer returns a HTTPS server that mocks the basic functionality required to validate K3s join tokens.
|
|
// Each call to this function will generate new CA and server certificates unique to the returned server.
|
|
func newTLSServer(t *testing.T, username, password string, sendWrongCA bool) *httptest.Server {
|
|
var server *httptest.Server
|
|
server = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1-k3s/server-bootstrap" {
|
|
if authUsername, authPassword, ok := r.BasicAuth(); ok != true || authPassword != password || authUsername != username {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
bootstrapData := &config.ControlRuntimeBootstrap{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := bootstrap.ReadFromDisk(w, bootstrapData); err != nil {
|
|
t.Errorf("failed to write bootstrap: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if r.URL.Path == "/cacerts" {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
if _, err := w.Write(getServerCA(server)); err != nil {
|
|
t.Errorf("Failed to write cacerts: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
|
}))
|
|
|
|
// Create new CA cert and key
|
|
caCert, caKey, err := factory.GenCA()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Generate new server cert; reuse the key from the CA
|
|
cfg := cert.Config{
|
|
CommonName: "localhost",
|
|
Organization: []string{"testing"},
|
|
Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
AltNames: cert.AltNames{
|
|
DNSNames: []string{"localhost"},
|
|
IPs: []net.IP{net.IPv4(127, 0, 0, 1)},
|
|
},
|
|
}
|
|
serverCert, err := cert.NewSignedCert(cfg, caKey, caCert, caKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Bind server and CA certs into chain for TLS listener configuration
|
|
server.TLS = &tls.Config{}
|
|
server.TLS.Certificates = []tls.Certificate{
|
|
{Certificate: [][]byte{serverCert.Raw}, Leaf: serverCert, PrivateKey: caKey},
|
|
{Certificate: [][]byte{caCert.Raw}, Leaf: caCert},
|
|
}
|
|
|
|
if sendWrongCA {
|
|
// Create new CA cert and key and use that as the CA cert instead of the one that actually signed the server cert
|
|
badCert, _, err := factory.GenCA()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server.TLS.Certificates[1].Certificate[0] = badCert.Raw
|
|
server.TLS.Certificates[1].Leaf = badCert
|
|
}
|
|
|
|
server.StartTLS()
|
|
return server
|
|
}
|
|
|
|
// getServerCA returns a byte slice containing the PEM encoding of the server's CA certificate
|
|
func getServerCA(server *httptest.Server) []byte {
|
|
bytes := []byte{}
|
|
for i, cert := range server.TLS.Certificates {
|
|
if i == 0 {
|
|
continue // Just return the chain, not the leaf server cert
|
|
}
|
|
bytes = append(bytes, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]})...)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
// writeServerCA writes the PEM-encoded server certificate to a given path
|
|
func writeServerCA(server *httptest.Server, path string) error {
|
|
certOut, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer certOut.Close()
|
|
|
|
if _, err := certOut.Write(getServerCA(server)); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|