@@ -16,6 +16,7 @@ import (
16
16
"path"
17
17
"path/filepath"
18
18
"strings"
19
+ "sync"
19
20
"text/template" // html/template escapes some nonces
20
21
"time"
21
22
@@ -24,6 +25,7 @@ import (
24
25
"github.com/unrolled/secure"
25
26
"golang.org/x/exp/slices"
26
27
"golang.org/x/sync/errgroup"
28
+ "golang.org/x/sync/singleflight"
27
29
"golang.org/x/xerrors"
28
30
29
31
"github.com/coder/coder/coderd/httpapi"
@@ -48,7 +50,7 @@ func init() {
48
50
}
49
51
50
52
// 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 {
52
54
// html files are handled by a text/template. Non-html files
53
55
// are served by the default file server.
54
56
//
@@ -59,13 +61,43 @@ func Handler(siteFS fs.FS, binFS http.FileSystem) http.Handler {
59
61
panic (xerrors .Errorf ("Failed to return handler for static files. Html files failed to load: %w" , err ))
60
62
}
61
63
64
+ binHashCache := newBinHashCache (binFS , binHashes )
65
+
62
66
mux := http .NewServeMux ()
63
67
mux .Handle ("/bin/" , http .StripPrefix ("/bin" , http .HandlerFunc (func (rw http.ResponseWriter , r * http.Request ) {
64
68
// Convert underscores in the filename to hyphens. We eventually want to
65
69
// change our hyphen-based filenames to underscores, but we need to
66
70
// support both for now.
67
71
r .URL .Path = strings .ReplaceAll (r .URL .Path , "_" , "-" )
68
72
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.
69
101
http .FileServer (binFS ).ServeHTTP (rw , r )
70
102
})))
71
103
mux .Handle ("/" , http .FileServer (http .FS (siteFS ))) // All other non-html static files.
@@ -409,20 +441,23 @@ func htmlFiles(files fs.FS) (*htmlTemplates, error) {
409
441
}, nil
410
442
}
411
443
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 ) {
419
454
if dest == "" {
420
455
// No destination on fs, embedded fs is the only option.
421
456
binFS , err := fs .Sub (siteFS , "bin" )
422
457
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 )
424
459
}
425
- return http .FS (binFS ), nil
460
+ return http .FS (binFS ), nil , nil
426
461
}
427
462
428
463
dest = filepath .Join (dest , "bin" )
@@ -440,51 +475,63 @@ func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, error) {
440
475
files , err := fs .ReadDir (siteFS , "bin" )
441
476
if err != nil {
442
477
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
446
485
}
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 )
448
487
}
449
488
450
489
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.
453
491
binFS , err := fs .Sub (siteFS , "bin" )
454
492
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 )
456
494
}
457
- return http .FS (binFS ), nil
495
+ return http .FS (binFS ), nil , nil
458
496
}
459
497
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
463
505
}
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 )
465
507
}
466
508
defer archive .Close ()
467
509
468
- dir , err := mkdest ()
510
+ binFS , err := mkdest ()
469
511
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 )
471
518
}
472
519
473
- ok , err := verifyBinSha1IsCurrent (dest , siteFS )
520
+ ok , err := verifyBinSha1IsCurrent (dest , siteFS , shaFiles )
474
521
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 )
476
523
}
477
524
if ! ok {
478
525
n , err := extractBin (dest , archive )
479
526
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 )
481
528
}
482
529
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" )
484
531
}
485
532
}
486
533
487
- return dir , nil
534
+ return binFS , shaFiles , nil
488
535
}
489
536
490
537
func filterFiles (files []fs.DirEntry , names ... string ) []fs.DirEntry {
@@ -501,24 +548,32 @@ func filterFiles(files []fs.DirEntry, names ...string) []fs.DirEntry {
501
548
// errHashMismatch is a sentinel error used in verifyBinSha1IsCurrent.
502
549
var errHashMismatch = xerrors .New ("hash mismatch" )
503
550
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" )
506
553
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 )
508
555
}
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' }) {
512
559
parts := bytes .Split (line , []byte {' ' , '*' })
513
560
if len (parts ) != 2 {
514
- return false , xerrors .Errorf ("malformed sha1 file: %w" , err )
561
+ return nil , xerrors .Errorf ("malformed sha1 file: %w" , err )
515
562
}
516
- shaFiles [string (parts [1 ])] = parts [0 ]
563
+ shaFiles [string (parts [1 ])] = strings . ToLower ( string ( parts [0 ]))
517
564
}
518
565
if len (shaFiles ) == 0 {
519
- return false , xerrors .Errorf ("empty sha1 file: %w" , err )
566
+ return nil , xerrors .Errorf ("empty sha1 file: %w" , err )
520
567
}
521
568
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
+ }
522
577
b2 , err := os .ReadFile (filepath .Join (dest , "coder.sha1" ))
523
578
if err != nil {
524
579
if xerrors .Is (err , fs .ErrNotExist ) {
@@ -551,7 +606,7 @@ func verifyBinSha1IsCurrent(dest string, siteFS fs.FS) (ok bool, err error) {
551
606
}
552
607
return xerrors .Errorf ("hash file failed: %w" , err )
553
608
}
554
- if ! bytes . Equal (hash1 , hash2 ) {
609
+ if ! strings . EqualFold (hash1 , hash2 ) {
555
610
return errHashMismatch
556
611
}
557
612
return nil
@@ -570,24 +625,24 @@ func verifyBinSha1IsCurrent(dest string, siteFS fs.FS) (ok bool, err error) {
570
625
571
626
// sha1HashFile computes a SHA1 hash of the file, returning the hex
572
627
// representation.
573
- func sha1HashFile (name string ) ([] byte , error ) {
628
+ func sha1HashFile (name string ) (string , error ) {
574
629
//#nosec // Not used for cryptography.
575
630
hash := sha1 .New ()
576
631
f , err := os .Open (name )
577
632
if err != nil {
578
- return nil , err
633
+ return "" , err
579
634
}
580
635
defer f .Close ()
581
636
582
637
_ , err = io .Copy (hash , f )
583
638
if err != nil {
584
- return nil , err
639
+ return "" , err
585
640
}
586
641
587
642
b := make ([]byte , hash .Size ())
588
643
hash .Sum (b [:0 ])
589
644
590
- return [] byte ( hex .EncodeToString (b ) ), nil
645
+ return hex .EncodeToString (b ), nil
591
646
}
592
647
593
648
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
672
727
return
673
728
}
674
729
}
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