diff --git a/lib/httplib/httpheaders.go b/lib/httplib/httpheaders.go index af20ae6171d7c..401922e35fff3 100644 --- a/lib/httplib/httpheaders.go +++ b/lib/httplib/httpheaders.go @@ -144,6 +144,13 @@ func SetCacheHeaders(h http.Header, maxAge time.Duration) { h.Set("Cache-Control", fmt.Sprintf("max-age=%.f, immutable", maxAge.Seconds())) } +// SetEntityTagCacheHeaders tells proxies and browsers to cache the content +// and sets an ETag based on teleport version which can be used to check for modifications +func SetEntityTagCacheHeaders(h http.Header, etag string) { + h.Set("Cache-Control", "no-cache") + h.Set("ETag", etag) +} + // SetDefaultSecurityHeaders adds headers that should generally be considered safe defaults. It is expected that all // responses should be able to add these headers without negative impact. func SetDefaultSecurityHeaders(h http.Header) { diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index c5781ae8bb47a..4130cfa231d3c 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -475,6 +475,16 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { // serve the web UI from the embedded filesystem var indexPage *template.Template + // we will set our etag based on the teleport version and + // the webasset app hash if available. The version only will not + // suffice as it can cause incorrect caching for local development. + + // The hash of the webasset app.js is used to ensure that builds at + // different times or different OSes will be the same and not cause + // cache invalidation for production users. For example, using a timestamp + // at build time would cause different OS builds to be different, and timestamps + // at process start would mean multiple proxies would serving different etags) + etag := fmt.Sprintf("W/%q", teleport.Version) if cfg.StaticFS != nil { index, err := cfg.StaticFS.Open("/index.html") if err != nil { @@ -493,6 +503,13 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { h.Handle("GET", "/robots.txt", httplib.MakeHandler(serveRobotsTxt)) h.Handle("GET", "/web/config.js", h.WithUnauthenticatedLimiter(h.getWebConfig)) + + etagFromAppHash, err := readEtagFromAppHash(cfg.StaticFS) + if err != nil { + h.log.WithError(err).Error("Could not read apphash from embedded webassets. Using version only as ETag for Web UI assets.") + } else { + etag = etagFromAppHash + } } if cfg.NodeWatcher != nil { @@ -524,10 +541,18 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { // serve Web UI: if strings.HasPrefix(r.URL.Path, "/web/app") { + + // Check if the incoming request wants to check the version + // and if the version has not changed, return a Not Modified response + if match := r.Header.Get("If-None-Match"); match == etag { + w.WriteHeader(http.StatusNotModified) + return + } + fs := http.FileServer(cfg.StaticFS) fs = makeGzipHandler(fs) - fs = makeCacheHandler(fs) + fs = makeCacheHandler(fs, etag) http.StripPrefix("/web", fs).ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/web/") || r.URL.Path == "/web" { @@ -4598,3 +4623,21 @@ func serveRobotsTxt(w http.ResponseWriter, r *http.Request, p httprouter.Params) w.Write([]byte(robots)) return nil, nil } + +func readEtagFromAppHash(fs http.FileSystem) (string, error) { + hashFile, err := fs.Open("/apphash") + if err != nil { + return "", trace.Wrap(err) + } + defer hashFile.Close() + + appHash, err := io.ReadAll(hashFile) + if err != nil { + return "", trace.Wrap(err) + } + + versionWithHash := fmt.Sprintf("%s-%s", teleport.Version, string(appHash)) + etag := fmt.Sprintf("%q", versionWithHash) + + return etag, nil +} diff --git a/lib/web/cachehandler.go b/lib/web/cachehandler.go index 198d27f32fd2c..f90f8ce925190 100644 --- a/lib/web/cachehandler.go +++ b/lib/web/cachehandler.go @@ -20,15 +20,26 @@ package web import ( "net/http" + "path/filepath" + "slices" "time" "github.com/gravitational/teleport/lib/httplib" ) // makeCacheHandler adds support for gzip compression for given handler. -func makeCacheHandler(handler http.Handler) http.Handler { +func makeCacheHandler(handler http.Handler, etag string) http.Handler { + cachedFileTypes := []string{".woff", ".woff2", ".ttf"} + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - httplib.SetCacheHeaders(w.Header(), time.Hour*24*365 /* one year */) + // We can cache fonts "permanently" because we don't expect them to change. The rest of our + // assets will have an ETag associated with them (teleport version) that will allow us + // to conditionally send the updated assets or a 304 status (Not Modified) response + if slices.Contains(cachedFileTypes, filepath.Ext(r.URL.Path)) { + httplib.SetCacheHeaders(w.Header(), time.Hour*24*365 /* one year */) + } else { + httplib.SetEntityTagCacheHeaders(w.Header(), etag) + } handler.ServeHTTP(w, r) }) diff --git a/lib/web/cachehandler_test.go b/lib/web/cachehandler_test.go new file mode 100644 index 0000000000000..260ae9464718e --- /dev/null +++ b/lib/web/cachehandler_test.go @@ -0,0 +1,58 @@ +// Teleport +// Copyright (C) 2024 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 . + +package web + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMakeCacheHandler(t *testing.T) { + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + etag := "test-etag" + + recorder := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/testfile.woff", nil) + if err != nil { + t.Fatal(err) + } + + cacheHandler := makeCacheHandler(testHandler, etag) + + cacheHandler.ServeHTTP(recorder, req) + + expectedCacheControl := "max-age=" + strconv.Itoa(int(time.Hour*24*365/time.Second)) + ", immutable" + require.Equal(t, expectedCacheControl, recorder.Header().Get("Cache-Control")) + + req2, err := http.NewRequest("GET", "/testfile.css", nil) + if err != nil { + t.Fatal(err) + } + + cacheHandler.ServeHTTP(recorder, req2) + + require.Equal(t, etag, recorder.Header().Get("ETag")) +} diff --git a/web/packages/build/vite/apphash.ts b/web/packages/build/vite/apphash.ts new file mode 100644 index 0000000000000..e5ce1e281657e --- /dev/null +++ b/web/packages/build/vite/apphash.ts @@ -0,0 +1,43 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import { createHash } from 'crypto'; +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; + +// this plugin is used to generate a file containing the hash of the app.js +// bundle. Because we omit the hash from the filename, we don't have access to it +// in the build chain. We generate a hash using the same methods used when files +// passed by default to `augmentChunkHash` (hash.update). +// https://rollupjs.org/plugin-development/#augmentchunkhash +export function generateAppHashFile(outputDir: string, entryFilename: string) { + return { + name: 'app-hash-plugin', + generateBundle(_, bundle) { + // bundle is OutputChunk | OutputAsset. These types aren't exported + // by vite but by rollup, which isn't directly in our bundle so we + // will use `any` instead of installing rollup + // https://rollupjs.org/plugin-development/#generatebundle + const { code } = bundle[entryFilename] as any; + if (code) { + const hash = createHash('sha256').update(code).digest('base64'); + writeFileSync(resolve(outputDir, 'apphash'), hash); + } + }, + }; +} diff --git a/web/packages/build/vite/config.ts b/web/packages/build/vite/config.ts index 235dee9b94e67..d7013262f51d2 100644 --- a/web/packages/build/vite/config.ts +++ b/web/packages/build/vite/config.ts @@ -28,10 +28,12 @@ import wasm from 'vite-plugin-wasm'; import { htmlPlugin, transformPlugin } from './html'; import { getStyledComponentsConfig } from './styled'; +import { generateAppHashFile } from './apphash'; import type { UserConfig } from 'vite'; const DEFAULT_PROXY_TARGET = '127.0.0.1:3080'; +const ENTRY_FILE_NAME = 'app/app.js'; export function createViteConfig( rootDirectory: string, @@ -71,8 +73,8 @@ export function createViteConfig( emptyOutDir: true, rollupOptions: { output: { - // removes hashing from our entry point file - entryFileNames: 'app/app.js', + // removes hashing from our entry point file. + entryFileNames: ENTRY_FILE_NAME, // assist is still lazy loaded and the telemetry bundle breaks any // websocket connections if included in the bundle. We will leave these two // files out of the bundle but without hashing so they are still discoverable. @@ -100,6 +102,7 @@ export function createViteConfig( projects: [resolve(rootDirectory, 'tsconfig.json')], }), transformPlugin(), + generateAppHashFile(outputDirectory, ENTRY_FILE_NAME), wasm(), ], define: {