Skip to content

Commit

Permalink
feat: Support multiple active CAs in Web exports (#51301) (#51418)
Browse files Browse the repository at this point in the history
* Move /auth/export code to own file

* Implement "/auth/export?format=zip"

* Refactor existing tests

* Test format=zip

* Fix comment

* Use bytes.NewReader

* Remove lib/client.ExportAuthorities
  • Loading branch information
codingllama authored Jan 24, 2025
1 parent 8ba2546 commit 29e8f3d
Show file tree
Hide file tree
Showing 6 changed files with 398 additions and 226 deletions.
26 changes: 0 additions & 26 deletions lib/client/ca_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,32 +124,6 @@ func exportAllAuthorities(
return authorities, nil
}

// ExportAuthorities is the single-authority version of [ExportAllAuthorities].
// Soft-deprecated, prefer using [ExportAllAuthorities] and handling exports
// with more than one authority gracefully.
func ExportAuthorities(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) (string, error) {
// TODO(codingllama): Remove ExportAuthorities.
return exportAuthorities(ctx, client, req, ExportAllAuthorities)
}

func exportAuthorities(
ctx context.Context,
client authclient.ClientI,
req ExportAuthoritiesRequest,
exportAllFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) ([]*ExportedAuthority, error),
) (string, error) {
authorities, err := exportAllFunc(ctx, client, req)
if err != nil {
return "", trace.Wrap(err)
}
// At least one authority is guaranteed on success by both ExportAll methods.
if l := len(authorities); l > 1 {
return "", trace.BadParameter("export returned %d authorities, expected exactly one", l)
}

return string(authorities[0].Data), nil
}

func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest, exportSecrets bool) ([]*ExportedAuthority, error) {
var typesToExport []types.CertAuthType

Expand Down
23 changes: 2 additions & 21 deletions lib/client/ca_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"testing"
"time"
Expand Down Expand Up @@ -277,31 +276,13 @@ func TestExportAuthorities(t *testing.T) {
assertFunc(t, exported)
}

runUnaryTest := func(
t *testing.T,
exportFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) (string, error),
assertFunc func(t *testing.T, output string),
) {
exported, err := exportFunc(ctx, mockedAuthClient, tt.req)
tt.errorCheck(t, err)
if err != nil {
return
}

assertFunc(t, exported)
}

t.Run(tt.name, func(t *testing.T) {
t.Parallel()

t.Run(fmt.Sprintf("%s/ExportAllAuthorities", tt.name), func(t *testing.T) {
t.Run("ExportAllAuthorities", func(t *testing.T) {
runTest(t, ExportAllAuthorities, tt.assertNoSecrets)
})
t.Run(fmt.Sprintf("%s/ExportAuthorities", tt.name), func(t *testing.T) {
runUnaryTest(t, ExportAuthorities, tt.assertNoSecrets)
})

t.Run(fmt.Sprintf("%s/ExportAllAuthoritiesSecrets", tt.name), func(t *testing.T) {
t.Run("ExportAllAuthoritiesSecrets", func(t *testing.T) {
runTest(t, ExportAllAuthoritiesSecrets, tt.assertSecrets)
})
})
Expand Down
30 changes: 0 additions & 30 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -5197,36 +5197,6 @@ func SSOSetWebSessionAndRedirectURL(w http.ResponseWriter, r *http.Request, resp
return nil
}

// authExportPublic returns the CA Certs that can be used to set up a chain of trust which includes the current Teleport Cluster
//
// GET /webapi/sites/:site/auth/export?type=<auth type>
// GET /webapi/auth/export?type=<auth type>
func (h *Handler) authExportPublic(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
err := rateLimitRequest(r, h.limiter)
if err != nil {
http.Error(w, err.Error(), trace.ErrorToCode(err))
return
}
authorities, err := client.ExportAuthorities(
r.Context(),
h.GetProxyClient(),
client.ExportAuthoritiesRequest{
AuthType: r.URL.Query().Get("type"),
},
)
if err != nil {
h.log.WithError(err).Debug("Failed to generate CA Certs.")
http.Error(w, err.Error(), trace.ErrorToCode(err))
return
}

reader := strings.NewReader(authorities)

// ServeContent sets the correct headers: Content-Type, Content-Length and Accept-Ranges.
// It also handles the Range negotiation
http.ServeContent(w, r, "authorized_hosts.txt", time.Now(), reader)
}

const robots = `User-agent: *
Disallow: /`

Expand Down
149 changes: 0 additions & 149 deletions lib/web/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ import (
"compress/gzip"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -3869,153 +3867,6 @@ func mustCreateDatabase(t *testing.T, name, protocol, uri string) *types.Databas
return database
}

func TestAuthExport(t *testing.T) {
env := newWebPack(t, 1)
clusterName := env.server.ClusterName()

proxy := env.proxies[0]
pack := proxy.authPack(t, "[email protected]", nil)

validateTLSCertificateDERFunc := func(t *testing.T, b []byte) {
cert, err := x509.ParseCertificate(b)
require.NoError(t, err)
require.NotNil(t, cert, "ParseCertificate failed")
require.Equal(t, "localhost", cert.Subject.CommonName, "unexpected certificate subject CN")
}

validateTLSCertificatePEMFunc := func(t *testing.T, b []byte) {
pemBlock, _ := pem.Decode(b)
require.NotNil(t, pemBlock, "pem.Decode failed")

validateTLSCertificateDERFunc(t, pemBlock.Bytes)
}

for _, tt := range []struct {
name string
authType string
expectedStatus int
assertBody func(t *testing.T, bs []byte)
}{
{
name: "all",
authType: "",
expectedStatus: http.StatusOK,
assertBody: func(t *testing.T, b []byte) {
require.Contains(t, string(b), "@cert-authority localhost,*.localhost ssh-rsa ")
require.Contains(t, string(b), "cert-authority ssh-rsa")
},
},
{
name: "host",
authType: "host",
expectedStatus: http.StatusOK,
assertBody: func(t *testing.T, b []byte) {
require.Contains(t, string(b), "@cert-authority localhost,*.localhost ssh-rsa ")
},
},
{
name: "user",
authType: "user",
expectedStatus: http.StatusOK,
assertBody: func(t *testing.T, b []byte) {
require.Contains(t, string(b), "cert-authority ssh-rsa")
},
},
{
name: "windows",
authType: "windows",
expectedStatus: http.StatusOK,
assertBody: validateTLSCertificateDERFunc,
},
{
name: "db",
authType: "db",
expectedStatus: http.StatusOK,
assertBody: validateTLSCertificatePEMFunc,
},
{
name: "db-der",
authType: "db-der",
expectedStatus: http.StatusOK,
assertBody: validateTLSCertificateDERFunc,
},
{
name: "db-client",
authType: "db-client",
expectedStatus: http.StatusOK,
assertBody: validateTLSCertificatePEMFunc,
},
{
name: "db-client-der",
authType: "db-client-der",
expectedStatus: http.StatusOK,
assertBody: validateTLSCertificateDERFunc,
},
{
name: "tls",
authType: "tls",
expectedStatus: http.StatusOK,
assertBody: validateTLSCertificatePEMFunc,
},
{
name: "invalid",
authType: "invalid",
expectedStatus: http.StatusBadRequest,
assertBody: func(t *testing.T, b []byte) {
require.Contains(t, string(b), `"invalid" authority type is not supported`)
},
},
} {
t.Run(tt.name, func(t *testing.T) {
// export host certificate
t.Run("deprecated endpoint", func(t *testing.T) {
endpointExport := pack.clt.Endpoint("webapi", "sites", clusterName, "auth", "export")
authExportTestByEndpoint(t, endpointExport, tt.authType, tt.expectedStatus, tt.assertBody)
})
t.Run("new endpoint", func(t *testing.T) {
endpointExport := pack.clt.Endpoint("webapi", "auth", "export")
authExportTestByEndpoint(t, endpointExport, tt.authType, tt.expectedStatus, tt.assertBody)
})
})
}
}

func authExportTestByEndpoint(t *testing.T, endpointExport, authType string, expectedStatus int, assertBody func(t *testing.T, bs []byte)) {
ctx := context.Background()

if authType != "" {
endpointExport = fmt.Sprintf("%s?type=%s", endpointExport, authType)
}

reqCtx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()

req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, endpointExport, nil)
require.NoError(t, err)

anonHTTPClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}

resp, err := anonHTTPClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

bs, err := io.ReadAll(resp.Body)
require.NoError(t, err)

require.Equal(t, expectedStatus, resp.StatusCode, "invalid status code with body %s", string(bs))

require.NotEmpty(t, bs, "unexpected empty body from http response")
if assertBody != nil {
assertBody(t, bs)
}
}

func TestClusterDatabasesGet_NoRole(t *testing.T) {
env := newWebPack(t, 1)

Expand Down
121 changes: 121 additions & 0 deletions lib/web/ca_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package web

import (
"archive/zip"
"bytes"
"fmt"
"net/http"
"time"

"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"

"github.com/gravitational/teleport/lib/client"
)

// authExportPublic returns the CA Certs that can be used to set up a chain of trust which includes the current Teleport Cluster
//
// GET /webapi/sites/:site/auth/export?type=<auth type>
// GET /webapi/auth/export?type=<auth type>
func (h *Handler) authExportPublic(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
if err := h.authExportPublicError(w, r, p); err != nil {
http.Error(w, err.Error(), trace.ErrorToCode(err))
return
}

// Success output handled by authExportPublicError.
}

// authExportPublicError implements authExportPublic, except it returns an error
// in case of failure. Output is only written on success.
func (h *Handler) authExportPublicError(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
err := rateLimitRequest(r, h.limiter)
if err != nil {
return trace.Wrap(err)
}

query := r.URL.Query()
caType := query.Get("type") // validated by ExportAllAuthorities
format := query.Get("format")

const formatZip = "zip"
if format != "" && format != formatZip {
return trace.BadParameter("unsupported format %q", format)
}

ctx := r.Context()
authorities, err := client.ExportAllAuthorities(
ctx,
h.GetProxyClient(),
client.ExportAuthoritiesRequest{
AuthType: caType,
},
)
if err != nil {
h.logger.DebugContext(ctx, "Failed to generate CA Certs", "error", err)
return trace.Wrap(err)
}

if format == formatZip {
return h.authExportPublicZip(w, r, authorities)
}
if l := len(authorities); l > 1 {
return trace.BadParameter("found %d authorities to export, use format=%s to export all", l, formatZip)
}

// ServeContent sets the correct headers: Content-Type, Content-Length and Accept-Ranges.
// It also handles the Range negotiation
reader := bytes.NewReader(authorities[0].Data)
http.ServeContent(w, r, "authorized_hosts.txt", time.Now(), reader)
return nil
}

func (h *Handler) authExportPublicZip(
w http.ResponseWriter,
r *http.Request,
authorities []*client.ExportedAuthority,
) error {
now := h.clock.Now().UTC()

// Write authorities to a zip buffer as files named "ca$i.cert".
out := &bytes.Buffer{}
zipWriter := zip.NewWriter(out)
for i, authority := range authorities {
fh := &zip.FileHeader{
Name: fmt.Sprintf("ca%d.cer", i),
Method: zip.Deflate,
Modified: now,
}
fh.SetMode(0644)

fileWriter, err := zipWriter.CreateHeader(fh)
if err != nil {
return trace.Wrap(err)
}
fileWriter.Write(authority.Data)
}
if err := zipWriter.Close(); err != nil {
return trace.Wrap(err)
}

const zipName = "Teleport_CA.zip"
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, zipName))
http.ServeContent(w, r, zipName, now, bytes.NewReader(out.Bytes()))
return nil
}
Loading

0 comments on commit 29e8f3d

Please sign in to comment.