Skip to content

Commit b19d644

Browse files
authored
feat: add etag to slim binaries endpoint (#5750)
1 parent c377cd0 commit b19d644

File tree

3 files changed

+222
-58
lines changed

3 files changed

+222
-58
lines changed

coderd/coderd.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ func New(options *Options) *API {
197197
if siteCacheDir != "" {
198198
siteCacheDir = filepath.Join(siteCacheDir, "site")
199199
}
200-
binFS, err := site.ExtractOrReadBinFS(siteCacheDir, site.FS())
200+
binFS, binHashes, err := site.ExtractOrReadBinFS(siteCacheDir, site.FS())
201201
if err != nil {
202202
panic(xerrors.Errorf("read site bin failed: %w", err))
203203
}
@@ -213,7 +213,7 @@ func New(options *Options) *API {
213213
ID: uuid.New(),
214214
Options: options,
215215
RootHandler: r,
216-
siteHandler: site.Handler(site.FS(), binFS),
216+
siteHandler: site.Handler(site.FS(), binFS, binHashes),
217217
HTTPAuth: &HTTPAuthorizer{
218218
Authorizer: options.Authorizer,
219219
Logger: options.Logger,

site/site.go

+162-43
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"path"
1717
"path/filepath"
1818
"strings"
19+
"sync"
1920
"text/template" // html/template escapes some nonces
2021
"time"
2122

@@ -24,6 +25,7 @@ import (
2425
"github.com/unrolled/secure"
2526
"golang.org/x/exp/slices"
2627
"golang.org/x/sync/errgroup"
28+
"golang.org/x/sync/singleflight"
2729
"golang.org/x/xerrors"
2830

2931
"github.com/coder/coder/coderd/httpapi"
@@ -48,7 +50,7 @@ func init() {
4850
}
4951

5052
// Handler returns an HTTP handler for serving the static site.
51-
func Handler(siteFS fs.FS, binFS http.FileSystem) http.Handler {
53+
func Handler(siteFS fs.FS, binFS http.FileSystem, binHashes map[string]string) http.Handler {
5254
// html files are handled by a text/template. Non-html files
5355
// are served by the default file server.
5456
//
@@ -59,13 +61,43 @@ func Handler(siteFS fs.FS, binFS http.FileSystem) http.Handler {
5961
panic(xerrors.Errorf("Failed to return handler for static files. Html files failed to load: %w", err))
6062
}
6163

64+
binHashCache := newBinHashCache(binFS, binHashes)
65+
6266
mux := http.NewServeMux()
6367
mux.Handle("/bin/", http.StripPrefix("/bin", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
6468
// Convert underscores in the filename to hyphens. We eventually want to
6569
// change our hyphen-based filenames to underscores, but we need to
6670
// support both for now.
6771
r.URL.Path = strings.ReplaceAll(r.URL.Path, "_", "-")
6872

73+
// Set ETag header to the SHA1 hash of the file contents.
74+
name := filePath(r.URL.Path)
75+
if name == "" || name == "/" {
76+
// Serve the directory listing.
77+
http.FileServer(binFS).ServeHTTP(rw, r)
78+
return
79+
}
80+
if strings.Contains(name, "/") {
81+
// We only serve files from the root of this directory, so avoid any
82+
// shenanigans by blocking slashes in the URL path.
83+
http.NotFound(rw, r)
84+
return
85+
}
86+
hash, err := binHashCache.getHash(name)
87+
if xerrors.Is(err, os.ErrNotExist) {
88+
http.NotFound(rw, r)
89+
return
90+
}
91+
if err != nil {
92+
http.Error(rw, err.Error(), http.StatusInternalServerError)
93+
return
94+
}
95+
96+
// ETag header needs to be quoted.
97+
rw.Header().Set("ETag", fmt.Sprintf(`%q`, hash))
98+
99+
// http.FileServer will see the ETag header and automatically handle
100+
// If-Match and If-None-Match headers on the request properly.
69101
http.FileServer(binFS).ServeHTTP(rw, r)
70102
})))
71103
mux.Handle("/", http.FileServer(http.FS(siteFS))) // All other non-html static files.
@@ -409,20 +441,23 @@ func htmlFiles(files fs.FS) (*htmlTemplates, error) {
409441
}, nil
410442
}
411443

412-
// ExtractOrReadBinFS checks the provided fs for compressed coder
413-
// binaries and extracts them into dest/bin if found. As a fallback,
414-
// the provided FS is checked for a /bin directory, if it is non-empty
415-
// it is returned. Finally dest/bin is returned as a fallback allowing
416-
// binaries to be manually placed in dest (usually
417-
// ${CODER_CACHE_DIRECTORY}/site/bin).
418-
func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, error) {
444+
// ExtractOrReadBinFS checks the provided fs for compressed coder binaries and
445+
// extracts them into dest/bin if found. As a fallback, the provided FS is
446+
// checked for a /bin directory, if it is non-empty it is returned. Finally
447+
// dest/bin is returned as a fallback allowing binaries to be manually placed in
448+
// dest (usually ${CODER_CACHE_DIRECTORY}/site/bin).
449+
//
450+
// Returns a http.FileSystem that serves unpacked binaries, and a map of binary
451+
// name to SHA1 hash. The returned hash map may be incomplete or contain hashes
452+
// for missing files.
453+
func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, map[string]string, error) {
419454
if dest == "" {
420455
// No destination on fs, embedded fs is the only option.
421456
binFS, err := fs.Sub(siteFS, "bin")
422457
if err != nil {
423-
return nil, xerrors.Errorf("cache path is empty and embedded fs does not have /bin: %w", err)
458+
return nil, nil, xerrors.Errorf("cache path is empty and embedded fs does not have /bin: %w", err)
424459
}
425-
return http.FS(binFS), nil
460+
return http.FS(binFS), nil, nil
426461
}
427462

428463
dest = filepath.Join(dest, "bin")
@@ -440,51 +475,63 @@ func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, error) {
440475
files, err := fs.ReadDir(siteFS, "bin")
441476
if err != nil {
442477
if xerrors.Is(err, fs.ErrNotExist) {
443-
// Given fs does not have a bin directory,
444-
// serve from cache directory.
445-
return mkdest()
478+
// Given fs does not have a bin directory, serve from cache
479+
// directory without extracting anything.
480+
binFS, err := mkdest()
481+
if err != nil {
482+
return nil, nil, xerrors.Errorf("mkdest failed: %w", err)
483+
}
484+
return binFS, map[string]string{}, nil
446485
}
447-
return nil, xerrors.Errorf("site fs read dir failed: %w", err)
486+
return nil, nil, xerrors.Errorf("site fs read dir failed: %w", err)
448487
}
449488

450489
if len(filterFiles(files, "GITKEEP")) > 0 {
451-
// If there are other files than bin/GITKEEP,
452-
// serve the files.
490+
// If there are other files than bin/GITKEEP, serve the files.
453491
binFS, err := fs.Sub(siteFS, "bin")
454492
if err != nil {
455-
return nil, xerrors.Errorf("site fs sub dir failed: %w", err)
493+
return nil, nil, xerrors.Errorf("site fs sub dir failed: %w", err)
456494
}
457-
return http.FS(binFS), nil
495+
return http.FS(binFS), nil, nil
458496
}
459497

460-
// Nothing we can do, serve the cache directory,
461-
// thus allowing binaries to be places there.
462-
return mkdest()
498+
// Nothing we can do, serve the cache directory, thus allowing
499+
// binaries to be placed there.
500+
binFS, err := mkdest()
501+
if err != nil {
502+
return nil, nil, xerrors.Errorf("mkdest failed: %w", err)
503+
}
504+
return binFS, map[string]string{}, nil
463505
}
464-
return nil, xerrors.Errorf("open coder binary archive failed: %w", err)
506+
return nil, nil, xerrors.Errorf("open coder binary archive failed: %w", err)
465507
}
466508
defer archive.Close()
467509

468-
dir, err := mkdest()
510+
binFS, err := mkdest()
469511
if err != nil {
470-
return nil, err
512+
return nil, nil, err
513+
}
514+
515+
shaFiles, err := parseSHA1(siteFS)
516+
if err != nil {
517+
return nil, nil, xerrors.Errorf("parse sha1 file failed: %w", err)
471518
}
472519

473-
ok, err := verifyBinSha1IsCurrent(dest, siteFS)
520+
ok, err := verifyBinSha1IsCurrent(dest, siteFS, shaFiles)
474521
if err != nil {
475-
return nil, xerrors.Errorf("verify coder binaries sha1 failed: %w", err)
522+
return nil, nil, xerrors.Errorf("verify coder binaries sha1 failed: %w", err)
476523
}
477524
if !ok {
478525
n, err := extractBin(dest, archive)
479526
if err != nil {
480-
return nil, xerrors.Errorf("extract coder binaries failed: %w", err)
527+
return nil, nil, xerrors.Errorf("extract coder binaries failed: %w", err)
481528
}
482529
if n == 0 {
483-
return nil, xerrors.New("no files were extracted from coder binaries archive")
530+
return nil, nil, xerrors.New("no files were extracted from coder binaries archive")
484531
}
485532
}
486533

487-
return dir, nil
534+
return binFS, shaFiles, nil
488535
}
489536

490537
func filterFiles(files []fs.DirEntry, names ...string) []fs.DirEntry {
@@ -501,24 +548,32 @@ func filterFiles(files []fs.DirEntry, names ...string) []fs.DirEntry {
501548
// errHashMismatch is a sentinel error used in verifyBinSha1IsCurrent.
502549
var errHashMismatch = xerrors.New("hash mismatch")
503550

504-
func verifyBinSha1IsCurrent(dest string, siteFS fs.FS) (ok bool, err error) {
505-
b1, err := fs.ReadFile(siteFS, "bin/coder.sha1")
551+
func parseSHA1(siteFS fs.FS) (map[string]string, error) {
552+
b, err := fs.ReadFile(siteFS, "bin/coder.sha1")
506553
if err != nil {
507-
return false, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
554+
return nil, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
508555
}
509-
// Parse sha1 file.
510-
shaFiles := make(map[string][]byte)
511-
for _, line := range bytes.Split(bytes.TrimSpace(b1), []byte{'\n'}) {
556+
557+
shaFiles := make(map[string]string)
558+
for _, line := range bytes.Split(bytes.TrimSpace(b), []byte{'\n'}) {
512559
parts := bytes.Split(line, []byte{' ', '*'})
513560
if len(parts) != 2 {
514-
return false, xerrors.Errorf("malformed sha1 file: %w", err)
561+
return nil, xerrors.Errorf("malformed sha1 file: %w", err)
515562
}
516-
shaFiles[string(parts[1])] = parts[0]
563+
shaFiles[string(parts[1])] = strings.ToLower(string(parts[0]))
517564
}
518565
if len(shaFiles) == 0 {
519-
return false, xerrors.Errorf("empty sha1 file: %w", err)
566+
return nil, xerrors.Errorf("empty sha1 file: %w", err)
520567
}
521568

569+
return shaFiles, nil
570+
}
571+
572+
func verifyBinSha1IsCurrent(dest string, siteFS fs.FS, shaFiles map[string]string) (ok bool, err error) {
573+
b1, err := fs.ReadFile(siteFS, "bin/coder.sha1")
574+
if err != nil {
575+
return false, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
576+
}
522577
b2, err := os.ReadFile(filepath.Join(dest, "coder.sha1"))
523578
if err != nil {
524579
if xerrors.Is(err, fs.ErrNotExist) {
@@ -551,7 +606,7 @@ func verifyBinSha1IsCurrent(dest string, siteFS fs.FS) (ok bool, err error) {
551606
}
552607
return xerrors.Errorf("hash file failed: %w", err)
553608
}
554-
if !bytes.Equal(hash1, hash2) {
609+
if !strings.EqualFold(hash1, hash2) {
555610
return errHashMismatch
556611
}
557612
return nil
@@ -570,24 +625,24 @@ func verifyBinSha1IsCurrent(dest string, siteFS fs.FS) (ok bool, err error) {
570625

571626
// sha1HashFile computes a SHA1 hash of the file, returning the hex
572627
// representation.
573-
func sha1HashFile(name string) ([]byte, error) {
628+
func sha1HashFile(name string) (string, error) {
574629
//#nosec // Not used for cryptography.
575630
hash := sha1.New()
576631
f, err := os.Open(name)
577632
if err != nil {
578-
return nil, err
633+
return "", err
579634
}
580635
defer f.Close()
581636

582637
_, err = io.Copy(hash, f)
583638
if err != nil {
584-
return nil, err
639+
return "", err
585640
}
586641

587642
b := make([]byte, hash.Size())
588643
hash.Sum(b[:0])
589644

590-
return []byte(hex.EncodeToString(b)), nil
645+
return hex.EncodeToString(b), nil
591646
}
592647

593648
func extractBin(dest string, r io.Reader) (numExtracted int, err error) {
@@ -672,3 +727,67 @@ func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPa
672727
return
673728
}
674729
}
730+
731+
type binHashCache struct {
732+
binFS http.FileSystem
733+
734+
hashes map[string]string
735+
mut sync.RWMutex
736+
sf singleflight.Group
737+
sem chan struct{}
738+
}
739+
740+
func newBinHashCache(binFS http.FileSystem, binHashes map[string]string) *binHashCache {
741+
b := &binHashCache{
742+
binFS: binFS,
743+
hashes: make(map[string]string, len(binHashes)),
744+
mut: sync.RWMutex{},
745+
sf: singleflight.Group{},
746+
sem: make(chan struct{}, 4),
747+
}
748+
// Make a copy since we're gonna be mutating it.
749+
for k, v := range binHashes {
750+
b.hashes[k] = v
751+
}
752+
753+
return b
754+
}
755+
756+
func (b *binHashCache) getHash(name string) (string, error) {
757+
b.mut.RLock()
758+
hash, ok := b.hashes[name]
759+
b.mut.RUnlock()
760+
if ok {
761+
return hash, nil
762+
}
763+
764+
// Avoid DOS by using a pool, and only doing work once per file.
765+
v, err, _ := b.sf.Do(name, func() (interface{}, error) {
766+
b.sem <- struct{}{}
767+
defer func() { <-b.sem }()
768+
769+
f, err := b.binFS.Open(name)
770+
if err != nil {
771+
return "", err
772+
}
773+
defer f.Close()
774+
775+
h := sha1.New() //#nosec // Not used for cryptography.
776+
_, err = io.Copy(h, f)
777+
if err != nil {
778+
return "", err
779+
}
780+
781+
hash := hex.EncodeToString(h.Sum(nil))
782+
b.mut.Lock()
783+
b.hashes[name] = hash
784+
b.mut.Unlock()
785+
return hash, nil
786+
})
787+
if err != nil {
788+
return "", err
789+
}
790+
791+
//nolint:forcetypeassert
792+
return strings.ToLower(v.(string)), nil
793+
}

0 commit comments

Comments
 (0)