Skip to content

Commit d007daa

Browse files
authored
feat: Support multiple active CAs in Web exports (#51301) (#51420)
* 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
1 parent e6c2379 commit d007daa

File tree

6 files changed

+398
-226
lines changed

6 files changed

+398
-226
lines changed

lib/client/ca_export.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -124,32 +124,6 @@ func exportAllAuthorities(
124124
return authorities, nil
125125
}
126126

127-
// ExportAuthorities is the single-authority version of [ExportAllAuthorities].
128-
// Soft-deprecated, prefer using [ExportAllAuthorities] and handling exports
129-
// with more than one authority gracefully.
130-
func ExportAuthorities(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest) (string, error) {
131-
// TODO(codingllama): Remove ExportAuthorities.
132-
return exportAuthorities(ctx, client, req, ExportAllAuthorities)
133-
}
134-
135-
func exportAuthorities(
136-
ctx context.Context,
137-
client authclient.ClientI,
138-
req ExportAuthoritiesRequest,
139-
exportAllFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) ([]*ExportedAuthority, error),
140-
) (string, error) {
141-
authorities, err := exportAllFunc(ctx, client, req)
142-
if err != nil {
143-
return "", trace.Wrap(err)
144-
}
145-
// At least one authority is guaranteed on success by both ExportAll methods.
146-
if l := len(authorities); l > 1 {
147-
return "", trace.BadParameter("export returned %d authorities, expected exactly one", l)
148-
}
149-
150-
return string(authorities[0].Data), nil
151-
}
152-
153127
func exportAuth(ctx context.Context, client authclient.ClientI, req ExportAuthoritiesRequest, exportSecrets bool) ([]*ExportedAuthority, error) {
154128
var typesToExport []types.CertAuthType
155129

lib/client/ca_export_test.go

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
"crypto/x509"
2828
"crypto/x509/pkix"
2929
"encoding/pem"
30-
"fmt"
3130
"math/big"
3231
"testing"
3332
"time"
@@ -290,31 +289,13 @@ func TestExportAuthorities(t *testing.T) {
290289
assertFunc(t, exported)
291290
}
292291

293-
runUnaryTest := func(
294-
t *testing.T,
295-
exportFunc func(context.Context, authclient.ClientI, ExportAuthoritiesRequest) (string, error),
296-
assertFunc func(t *testing.T, output string),
297-
) {
298-
exported, err := exportFunc(ctx, mockedAuthClient, tt.req)
299-
tt.errorCheck(t, err)
300-
if err != nil {
301-
return
302-
}
303-
304-
assertFunc(t, exported)
305-
}
306-
307292
t.Run(tt.name, func(t *testing.T) {
308293
t.Parallel()
309294

310-
t.Run(fmt.Sprintf("%s/ExportAllAuthorities", tt.name), func(t *testing.T) {
295+
t.Run("ExportAllAuthorities", func(t *testing.T) {
311296
runTest(t, ExportAllAuthorities, tt.assertNoSecrets)
312297
})
313-
t.Run(fmt.Sprintf("%s/ExportAuthorities", tt.name), func(t *testing.T) {
314-
runUnaryTest(t, ExportAuthorities, tt.assertNoSecrets)
315-
})
316-
317-
t.Run(fmt.Sprintf("%s/ExportAllAuthoritiesSecrets", tt.name), func(t *testing.T) {
298+
t.Run("ExportAllAuthoritiesSecrets", func(t *testing.T) {
318299
runTest(t, ExportAllAuthoritiesSecrets, tt.assertSecrets)
319300
})
320301
})

lib/web/apiserver.go

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4792,36 +4792,6 @@ func SSOSetWebSessionAndRedirectURL(w http.ResponseWriter, r *http.Request, resp
47924792
return nil
47934793
}
47944794

4795-
// authExportPublic returns the CA Certs that can be used to set up a chain of trust which includes the current Teleport Cluster
4796-
//
4797-
// GET /webapi/sites/:site/auth/export?type=<auth type>
4798-
// GET /webapi/auth/export?type=<auth type>
4799-
func (h *Handler) authExportPublic(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
4800-
err := rateLimitRequest(r, h.limiter)
4801-
if err != nil {
4802-
http.Error(w, err.Error(), trace.ErrorToCode(err))
4803-
return
4804-
}
4805-
authorities, err := client.ExportAuthorities(
4806-
r.Context(),
4807-
h.GetProxyClient(),
4808-
client.ExportAuthoritiesRequest{
4809-
AuthType: r.URL.Query().Get("type"),
4810-
},
4811-
)
4812-
if err != nil {
4813-
h.log.WithError(err).Debug("Failed to generate CA Certs.")
4814-
http.Error(w, err.Error(), trace.ErrorToCode(err))
4815-
return
4816-
}
4817-
4818-
reader := strings.NewReader(authorities)
4819-
4820-
// ServeContent sets the correct headers: Content-Type, Content-Length and Accept-Ranges.
4821-
// It also handles the Range negotiation
4822-
http.ServeContent(w, r, "authorized_hosts.txt", time.Now(), reader)
4823-
}
4824-
48254795
const robots = `User-agent: *
48264796
Disallow: /`
48274797

lib/web/apiserver_test.go

Lines changed: 0 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@ import (
2525
"compress/gzip"
2626
"context"
2727
"crypto/tls"
28-
"crypto/x509"
2928
"encoding/base64"
3029
"encoding/hex"
3130
"encoding/json"
32-
"encoding/pem"
3331
"errors"
3432
"fmt"
3533
"io"
@@ -3884,153 +3882,6 @@ func mustCreateDatabase(t *testing.T, name, protocol, uri string) *types.Databas
38843882
return database
38853883
}
38863884

3887-
func TestAuthExport(t *testing.T) {
3888-
env := newWebPack(t, 1)
3889-
clusterName := env.server.ClusterName()
3890-
3891-
proxy := env.proxies[0]
3892-
pack := proxy.authPack(t, "test-user@example.com", nil)
3893-
3894-
validateTLSCertificateDERFunc := func(t *testing.T, b []byte) {
3895-
cert, err := x509.ParseCertificate(b)
3896-
require.NoError(t, err)
3897-
require.NotNil(t, cert, "ParseCertificate failed")
3898-
require.Equal(t, "localhost", cert.Subject.CommonName, "unexpected certificate subject CN")
3899-
}
3900-
3901-
validateTLSCertificatePEMFunc := func(t *testing.T, b []byte) {
3902-
pemBlock, _ := pem.Decode(b)
3903-
require.NotNil(t, pemBlock, "pem.Decode failed")
3904-
3905-
validateTLSCertificateDERFunc(t, pemBlock.Bytes)
3906-
}
3907-
3908-
for _, tt := range []struct {
3909-
name string
3910-
authType string
3911-
expectedStatus int
3912-
assertBody func(t *testing.T, bs []byte)
3913-
}{
3914-
{
3915-
name: "all",
3916-
authType: "",
3917-
expectedStatus: http.StatusOK,
3918-
assertBody: func(t *testing.T, b []byte) {
3919-
require.Contains(t, string(b), "@cert-authority localhost,*.localhost ssh-rsa ")
3920-
require.Contains(t, string(b), "cert-authority ssh-rsa")
3921-
},
3922-
},
3923-
{
3924-
name: "host",
3925-
authType: "host",
3926-
expectedStatus: http.StatusOK,
3927-
assertBody: func(t *testing.T, b []byte) {
3928-
require.Contains(t, string(b), "@cert-authority localhost,*.localhost ssh-rsa ")
3929-
},
3930-
},
3931-
{
3932-
name: "user",
3933-
authType: "user",
3934-
expectedStatus: http.StatusOK,
3935-
assertBody: func(t *testing.T, b []byte) {
3936-
require.Contains(t, string(b), "cert-authority ssh-rsa")
3937-
},
3938-
},
3939-
{
3940-
name: "windows",
3941-
authType: "windows",
3942-
expectedStatus: http.StatusOK,
3943-
assertBody: validateTLSCertificateDERFunc,
3944-
},
3945-
{
3946-
name: "db",
3947-
authType: "db",
3948-
expectedStatus: http.StatusOK,
3949-
assertBody: validateTLSCertificatePEMFunc,
3950-
},
3951-
{
3952-
name: "db-der",
3953-
authType: "db-der",
3954-
expectedStatus: http.StatusOK,
3955-
assertBody: validateTLSCertificateDERFunc,
3956-
},
3957-
{
3958-
name: "db-client",
3959-
authType: "db-client",
3960-
expectedStatus: http.StatusOK,
3961-
assertBody: validateTLSCertificatePEMFunc,
3962-
},
3963-
{
3964-
name: "db-client-der",
3965-
authType: "db-client-der",
3966-
expectedStatus: http.StatusOK,
3967-
assertBody: validateTLSCertificateDERFunc,
3968-
},
3969-
{
3970-
name: "tls",
3971-
authType: "tls",
3972-
expectedStatus: http.StatusOK,
3973-
assertBody: validateTLSCertificatePEMFunc,
3974-
},
3975-
{
3976-
name: "invalid",
3977-
authType: "invalid",
3978-
expectedStatus: http.StatusBadRequest,
3979-
assertBody: func(t *testing.T, b []byte) {
3980-
require.Contains(t, string(b), `"invalid" authority type is not supported`)
3981-
},
3982-
},
3983-
} {
3984-
t.Run(tt.name, func(t *testing.T) {
3985-
// export host certificate
3986-
t.Run("deprecated endpoint", func(t *testing.T) {
3987-
endpointExport := pack.clt.Endpoint("webapi", "sites", clusterName, "auth", "export")
3988-
authExportTestByEndpoint(t, endpointExport, tt.authType, tt.expectedStatus, tt.assertBody)
3989-
})
3990-
t.Run("new endpoint", func(t *testing.T) {
3991-
endpointExport := pack.clt.Endpoint("webapi", "auth", "export")
3992-
authExportTestByEndpoint(t, endpointExport, tt.authType, tt.expectedStatus, tt.assertBody)
3993-
})
3994-
})
3995-
}
3996-
}
3997-
3998-
func authExportTestByEndpoint(t *testing.T, endpointExport, authType string, expectedStatus int, assertBody func(t *testing.T, bs []byte)) {
3999-
ctx := context.Background()
4000-
4001-
if authType != "" {
4002-
endpointExport = fmt.Sprintf("%s?type=%s", endpointExport, authType)
4003-
}
4004-
4005-
reqCtx, cancel := context.WithTimeout(ctx, time.Second)
4006-
defer cancel()
4007-
4008-
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, endpointExport, nil)
4009-
require.NoError(t, err)
4010-
4011-
anonHTTPClient := &http.Client{
4012-
Transport: &http.Transport{
4013-
TLSClientConfig: &tls.Config{
4014-
InsecureSkipVerify: true,
4015-
},
4016-
},
4017-
}
4018-
4019-
resp, err := anonHTTPClient.Do(req)
4020-
require.NoError(t, err)
4021-
defer resp.Body.Close()
4022-
4023-
bs, err := io.ReadAll(resp.Body)
4024-
require.NoError(t, err)
4025-
4026-
require.Equal(t, expectedStatus, resp.StatusCode, "invalid status code with body %s", string(bs))
4027-
4028-
require.NotEmpty(t, bs, "unexpected empty body from http response")
4029-
if assertBody != nil {
4030-
assertBody(t, bs)
4031-
}
4032-
}
4033-
40343885
func TestClusterDatabasesGet_NoRole(t *testing.T) {
40353886
env := newWebPack(t, 1)
40363887

lib/web/ca_export.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Teleport
2+
// Copyright (C) 2025 Gravitational, Inc.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package web
18+
19+
import (
20+
"archive/zip"
21+
"bytes"
22+
"fmt"
23+
"net/http"
24+
"time"
25+
26+
"github.com/gravitational/trace"
27+
"github.com/julienschmidt/httprouter"
28+
29+
"github.com/gravitational/teleport/lib/client"
30+
)
31+
32+
// authExportPublic returns the CA Certs that can be used to set up a chain of trust which includes the current Teleport Cluster
33+
//
34+
// GET /webapi/sites/:site/auth/export?type=<auth type>
35+
// GET /webapi/auth/export?type=<auth type>
36+
func (h *Handler) authExportPublic(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
37+
if err := h.authExportPublicError(w, r, p); err != nil {
38+
http.Error(w, err.Error(), trace.ErrorToCode(err))
39+
return
40+
}
41+
42+
// Success output handled by authExportPublicError.
43+
}
44+
45+
// authExportPublicError implements authExportPublic, except it returns an error
46+
// in case of failure. Output is only written on success.
47+
func (h *Handler) authExportPublicError(w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
48+
err := rateLimitRequest(r, h.limiter)
49+
if err != nil {
50+
return trace.Wrap(err)
51+
}
52+
53+
query := r.URL.Query()
54+
caType := query.Get("type") // validated by ExportAllAuthorities
55+
format := query.Get("format")
56+
57+
const formatZip = "zip"
58+
if format != "" && format != formatZip {
59+
return trace.BadParameter("unsupported format %q", format)
60+
}
61+
62+
ctx := r.Context()
63+
authorities, err := client.ExportAllAuthorities(
64+
ctx,
65+
h.GetProxyClient(),
66+
client.ExportAuthoritiesRequest{
67+
AuthType: caType,
68+
},
69+
)
70+
if err != nil {
71+
h.logger.DebugContext(ctx, "Failed to generate CA Certs", "error", err)
72+
return trace.Wrap(err)
73+
}
74+
75+
if format == formatZip {
76+
return h.authExportPublicZip(w, r, authorities)
77+
}
78+
if l := len(authorities); l > 1 {
79+
return trace.BadParameter("found %d authorities to export, use format=%s to export all", l, formatZip)
80+
}
81+
82+
// ServeContent sets the correct headers: Content-Type, Content-Length and Accept-Ranges.
83+
// It also handles the Range negotiation
84+
reader := bytes.NewReader(authorities[0].Data)
85+
http.ServeContent(w, r, "authorized_hosts.txt", time.Now(), reader)
86+
return nil
87+
}
88+
89+
func (h *Handler) authExportPublicZip(
90+
w http.ResponseWriter,
91+
r *http.Request,
92+
authorities []*client.ExportedAuthority,
93+
) error {
94+
now := h.clock.Now().UTC()
95+
96+
// Write authorities to a zip buffer as files named "ca$i.cert".
97+
out := &bytes.Buffer{}
98+
zipWriter := zip.NewWriter(out)
99+
for i, authority := range authorities {
100+
fh := &zip.FileHeader{
101+
Name: fmt.Sprintf("ca%d.cer", i),
102+
Method: zip.Deflate,
103+
Modified: now,
104+
}
105+
fh.SetMode(0644)
106+
107+
fileWriter, err := zipWriter.CreateHeader(fh)
108+
if err != nil {
109+
return trace.Wrap(err)
110+
}
111+
fileWriter.Write(authority.Data)
112+
}
113+
if err := zipWriter.Close(); err != nil {
114+
return trace.Wrap(err)
115+
}
116+
117+
const zipName = "Teleport_CA.zip"
118+
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, zipName))
119+
http.ServeContent(w, r, zipName, now, bytes.NewReader(out.Bytes()))
120+
return nil
121+
}

0 commit comments

Comments
 (0)