mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
f6a8a5f7be
If `httpmw.ExtractAPIKey` fails when we are rendering an HTML page, the HTML output will be dirtied with the error repsonse and the HTTP status will also be wrong. The use of this function in the `renderHTMLWithState` is additive, and failure means we simply can't embed static data. To fix this, we can simply pass a `http.ResponseWriter` that is no-op. Fixes #8351
837 lines
22 KiB
Go
837 lines
22 KiB
Go
package site
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1" //#nosec // Not used for cryptography.
|
|
"database/sql"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
htmltemplate "html/template"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"text/template" // html/template escapes some nonces
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/justinas/nosurf"
|
|
"github.com/klauspost/compress/zstd"
|
|
"github.com/unrolled/secure"
|
|
"golang.org/x/exp/slices"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/sync/singleflight"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/buildinfo"
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/database/db2sdk"
|
|
"github.com/coder/coder/coderd/database/dbauthz"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/codersdk"
|
|
)
|
|
|
|
// We always embed the error page HTML because it it doesn't need to be built,
|
|
// and it's tiny and doesn't contribute much to the binary size.
|
|
var (
|
|
//go:embed static/error.html
|
|
errorHTML string
|
|
|
|
errorTemplate *htmltemplate.Template
|
|
)
|
|
|
|
func init() {
|
|
var err error
|
|
errorTemplate, err = htmltemplate.New("error").Parse(errorHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
type Options struct {
|
|
BinFS http.FileSystem
|
|
BinHashes map[string]string
|
|
Database database.Store
|
|
SiteFS fs.FS
|
|
OAuth2Configs *httpmw.OAuth2Configs
|
|
}
|
|
|
|
func New(opts *Options) *Handler {
|
|
handler := &Handler{
|
|
opts: opts,
|
|
secureHeaders: secureHeaders(),
|
|
}
|
|
|
|
// html files are handled by a text/template. Non-html files
|
|
// are served by the default file server.
|
|
var err error
|
|
handler.htmlTemplates, err = findAndParseHTMLFiles(opts.SiteFS)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to parse html files: %v", err))
|
|
}
|
|
|
|
binHashCache := newBinHashCache(opts.BinFS, opts.BinHashes)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/bin/", http.StripPrefix("/bin", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
// Convert underscores in the filename to hyphens. We eventually want to
|
|
// change our hyphen-based filenames to underscores, but we need to
|
|
// support both for now.
|
|
r.URL.Path = strings.ReplaceAll(r.URL.Path, "_", "-")
|
|
|
|
// Set ETag header to the SHA1 hash of the file contents.
|
|
name := filePath(r.URL.Path)
|
|
if name == "" || name == "/" {
|
|
// Serve the directory listing.
|
|
http.FileServer(opts.BinFS).ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
if strings.Contains(name, "/") {
|
|
// We only serve files from the root of this directory, so avoid any
|
|
// shenanigans by blocking slashes in the URL path.
|
|
http.NotFound(rw, r)
|
|
return
|
|
}
|
|
hash, err := binHashCache.getHash(name)
|
|
if xerrors.Is(err, os.ErrNotExist) {
|
|
http.NotFound(rw, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// ETag header needs to be quoted.
|
|
rw.Header().Set("ETag", fmt.Sprintf(`%q`, hash))
|
|
|
|
// http.FileServer will see the ETag header and automatically handle
|
|
// If-Match and If-None-Match headers on the request properly.
|
|
http.FileServer(opts.BinFS).ServeHTTP(rw, r)
|
|
})))
|
|
mux.Handle("/", http.FileServer(http.FS(opts.SiteFS)))
|
|
|
|
buildInfo := codersdk.BuildInfoResponse{
|
|
ExternalURL: buildinfo.ExternalURL(),
|
|
Version: buildinfo.Version(),
|
|
}
|
|
buildInfoResponse, err := json.Marshal(buildInfo)
|
|
if err != nil {
|
|
panic("failed to marshal build info: " + err.Error())
|
|
}
|
|
handler.buildInfoJSON = html.EscapeString(string(buildInfoResponse))
|
|
handler.handler = mux.ServeHTTP
|
|
|
|
return handler
|
|
}
|
|
|
|
type Handler struct {
|
|
opts *Options
|
|
|
|
secureHeaders *secure.Secure
|
|
handler http.HandlerFunc
|
|
htmlTemplates *template.Template
|
|
|
|
buildInfoJSON string
|
|
|
|
AppearanceFetcher func(ctx context.Context) (codersdk.AppearanceConfig, error)
|
|
// RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the
|
|
// regions if the user does not have the correct permissions.
|
|
RegionsFetcher func(ctx context.Context) (any, error)
|
|
|
|
Entitlements atomic.Pointer[codersdk.Entitlements]
|
|
Experiments atomic.Pointer[codersdk.Experiments]
|
|
}
|
|
|
|
func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
|
err := h.secureHeaders.Process(rw, r)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// reqFile is the static file requested
|
|
reqFile := filePath(r.URL.Path)
|
|
state := htmlState{
|
|
// Token is the CSRF token for the given request
|
|
CSRF: csrfState{Token: nosurf.Token(r)},
|
|
BuildInfo: h.buildInfoJSON,
|
|
}
|
|
|
|
// First check if it's a file we have in our templates
|
|
if h.serveHTML(rw, r, reqFile, state) {
|
|
return
|
|
}
|
|
|
|
switch {
|
|
// If requesting binaries, serve straight up.
|
|
case reqFile == "bin" || strings.HasPrefix(reqFile, "bin/"):
|
|
h.handler.ServeHTTP(rw, r)
|
|
return
|
|
// If the original file path exists we serve it.
|
|
case h.exists(reqFile):
|
|
if ShouldCacheFile(reqFile) {
|
|
rw.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
h.handler.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
|
|
// Serve the file assuming it's an html file
|
|
// This matches paths like `/app/terminal.html`
|
|
r.URL.Path = strings.TrimSuffix(r.URL.Path, "/")
|
|
r.URL.Path += ".html"
|
|
|
|
reqFile = filePath(r.URL.Path)
|
|
// All html files should be served by the htmlFile templates
|
|
if h.serveHTML(rw, r, reqFile, state) {
|
|
return
|
|
}
|
|
|
|
// If we don't have the file... we should redirect to `/`
|
|
// for our single-page-app.
|
|
r.URL.Path = "/"
|
|
if h.serveHTML(rw, r, "", state) {
|
|
return
|
|
}
|
|
|
|
// This will send a correct 404
|
|
h.handler.ServeHTTP(rw, r)
|
|
}
|
|
|
|
// filePath returns the filepath of the requested file.
|
|
func filePath(p string) string {
|
|
if !strings.HasPrefix(p, "/") {
|
|
p = "/" + p
|
|
}
|
|
return strings.TrimPrefix(path.Clean(p), "/")
|
|
}
|
|
|
|
func (h *Handler) exists(filePath string) bool {
|
|
f, err := h.opts.SiteFS.Open(filePath)
|
|
if err == nil {
|
|
_ = f.Close()
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
type htmlState struct {
|
|
CSRF csrfState
|
|
|
|
// Below are HTML escaped JSON strings of the respective structs.
|
|
BuildInfo string
|
|
User string
|
|
Entitlements string
|
|
Appearance string
|
|
Experiments string
|
|
Regions string
|
|
}
|
|
|
|
type csrfState struct {
|
|
Token string
|
|
}
|
|
|
|
func ShouldCacheFile(reqFile string) bool {
|
|
// Images, favicons and uniquely content hashed bundle assets should be
|
|
// cached. By default, we cache everything in the site/out directory except
|
|
// for deny-listed items enumerated here. The reason for this approach is that
|
|
// cache invalidation techniques should be used by default for all build
|
|
// processed assets. The scenarios where we don't use cache invalidation
|
|
// techniques are one-offs or things that should have invalidation in the
|
|
// future.
|
|
denyListedSuffixes := []string{
|
|
".html",
|
|
"worker.js",
|
|
}
|
|
|
|
for _, suffix := range denyListedSuffixes {
|
|
if strings.HasSuffix(reqFile, suffix) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
|
|
if data, err := h.renderHTMLWithState(request, reqPath, state); err == nil {
|
|
if reqPath == "" {
|
|
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
|
|
reqPath = "index.html"
|
|
}
|
|
http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// renderWithState will render the file using the given nonce if the file exists
|
|
// as a template. If it does not, it will return an error.
|
|
func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if filePath == "" {
|
|
filePath = "index.html"
|
|
}
|
|
tmpl := h.htmlTemplates.Lookup(filePath)
|
|
if tmpl == nil {
|
|
return nil, xerrors.Errorf("template %q not found", filePath)
|
|
}
|
|
|
|
// Cookies are sent when requesting HTML, so we can get the user
|
|
// and pre-populate the state for the frontend to reduce requests.
|
|
// We use a noop response writer because we don't want to write
|
|
// anything to the response and break the HTML, an error means we
|
|
// simply don't pre-populate the state.
|
|
noopRW := noopResponseWriter{}
|
|
apiKey, actor, ok := httpmw.ExtractAPIKey(noopRW, r, httpmw.ExtractAPIKeyConfig{
|
|
Optional: true,
|
|
DB: h.opts.Database,
|
|
OAuth2Configs: h.opts.OAuth2Configs,
|
|
// Special case for site, we can always disable refresh here because
|
|
// the frontend will perform API requests if this fails.
|
|
DisableSessionExpiryRefresh: true,
|
|
RedirectToLogin: false,
|
|
SessionTokenFunc: nil,
|
|
})
|
|
if ok && apiKey != nil && actor != nil {
|
|
ctx := dbauthz.As(r.Context(), actor.Actor)
|
|
|
|
var eg errgroup.Group
|
|
var user database.User
|
|
orgIDs := []uuid.UUID{}
|
|
eg.Go(func() error {
|
|
var err error
|
|
user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
|
|
return err
|
|
})
|
|
eg.Go(func() error {
|
|
memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID})
|
|
if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
orgIDs = memberIDs[0].OrganizationIDs
|
|
return err
|
|
})
|
|
err := eg.Wait()
|
|
if err == nil {
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
user, err := json.Marshal(db2sdk.User(user, orgIDs))
|
|
if err == nil {
|
|
state.User = html.EscapeString(string(user))
|
|
}
|
|
}()
|
|
entitlements := h.Entitlements.Load()
|
|
if entitlements != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
entitlements, err := json.Marshal(entitlements)
|
|
if err == nil {
|
|
state.Entitlements = html.EscapeString(string(entitlements))
|
|
}
|
|
}()
|
|
}
|
|
if h.AppearanceFetcher != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
cfg, err := h.AppearanceFetcher(ctx)
|
|
if err == nil {
|
|
appearance, err := json.Marshal(cfg)
|
|
if err == nil {
|
|
state.Appearance = html.EscapeString(string(appearance))
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
if h.RegionsFetcher != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
regions, err := h.RegionsFetcher(ctx)
|
|
if err == nil {
|
|
regions, err := json.Marshal(regions)
|
|
if err == nil {
|
|
state.Regions = html.EscapeString(string(regions))
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
experiments := h.Experiments.Load()
|
|
if experiments != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
experiments, err := json.Marshal(experiments)
|
|
if err == nil {
|
|
state.Experiments = html.EscapeString(string(experiments))
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
}
|
|
|
|
err := tmpl.Execute(&buf, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// noopResponseWriter is a response writer that does nothing.
|
|
type noopResponseWriter struct{}
|
|
|
|
func (noopResponseWriter) Header() http.Header { return http.Header{} }
|
|
func (noopResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
func (noopResponseWriter) WriteHeader(int) {}
|
|
|
|
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
|
|
// It adds various headers to enforce browser security features.
|
|
func secureHeaders() *secure.Secure {
|
|
// Permissions-Policy can be used to disabled various browser features that we do not use.
|
|
// This can prevent an embedded iframe from accessing these features.
|
|
// If we support arbitrary iframes such as generic applications, we might need to add permissions
|
|
// based on the app here.
|
|
permissions := strings.Join([]string{
|
|
// =() means it is disabled
|
|
"accelerometer=()",
|
|
"autoplay=()",
|
|
"battery=()",
|
|
"camera=()",
|
|
"document-domain=()",
|
|
"geolocation=()",
|
|
"gyroscope=()",
|
|
"magnetometer=()",
|
|
"microphone=()",
|
|
"midi=()",
|
|
"payment=()",
|
|
"usb=()",
|
|
"vr=()",
|
|
"screen-wake-lock=()",
|
|
"xr-spatial-tracking=()",
|
|
}, ", ")
|
|
|
|
return secure.New(secure.Options{
|
|
PermissionsPolicy: permissions,
|
|
|
|
// Prevent the browser from sending Referrer header with requests
|
|
ReferrerPolicy: "no-referrer",
|
|
})
|
|
}
|
|
|
|
// findAndParseHTMLFiles recursively walks the file system passed finding all *.html files.
|
|
// The template returned has all html files parsed.
|
|
func findAndParseHTMLFiles(files fs.FS) (*template.Template, error) {
|
|
// root is the collection of html templates. All templates are named by their pathing.
|
|
// So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
|
|
root := template.New("")
|
|
|
|
rootPath := "."
|
|
err := fs.WalkDir(files, rootPath, func(filePath string, directory fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if directory.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
if filepath.Ext(directory.Name()) != ".html" {
|
|
return nil
|
|
}
|
|
|
|
file, err := files.Open(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tPath := strings.TrimPrefix(filePath, rootPath+string(filepath.Separator))
|
|
_, err = root.New(tPath).Parse(string(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
// ExtractOrReadBinFS checks the provided fs for compressed coder binaries and
|
|
// extracts them into dest/bin if found. As a fallback, the provided FS is
|
|
// checked for a /bin directory, if it is non-empty it is returned. Finally
|
|
// dest/bin is returned as a fallback allowing binaries to be manually placed in
|
|
// dest (usually ${CODER_CACHE_DIRECTORY}/site/bin).
|
|
//
|
|
// Returns a http.FileSystem that serves unpacked binaries, and a map of binary
|
|
// name to SHA1 hash. The returned hash map may be incomplete or contain hashes
|
|
// for missing files.
|
|
func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, map[string]string, error) {
|
|
if dest == "" {
|
|
// No destination on fs, embedded fs is the only option.
|
|
binFS, err := fs.Sub(siteFS, "bin")
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("cache path is empty and embedded fs does not have /bin: %w", err)
|
|
}
|
|
return http.FS(binFS), nil, nil
|
|
}
|
|
|
|
dest = filepath.Join(dest, "bin")
|
|
mkdest := func() (http.FileSystem, error) {
|
|
err := os.MkdirAll(dest, 0o700)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("mkdir failed: %w", err)
|
|
}
|
|
return http.Dir(dest), nil
|
|
}
|
|
|
|
archive, err := siteFS.Open("bin/coder.tar.zst")
|
|
if err != nil {
|
|
if xerrors.Is(err, fs.ErrNotExist) {
|
|
files, err := fs.ReadDir(siteFS, "bin")
|
|
if err != nil {
|
|
if xerrors.Is(err, fs.ErrNotExist) {
|
|
// Given fs does not have a bin directory, serve from cache
|
|
// directory without extracting anything.
|
|
binFS, err := mkdest()
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("mkdest failed: %w", err)
|
|
}
|
|
return binFS, map[string]string{}, nil
|
|
}
|
|
return nil, nil, xerrors.Errorf("site fs read dir failed: %w", err)
|
|
}
|
|
|
|
if len(filterFiles(files, "GITKEEP")) > 0 {
|
|
// If there are other files than bin/GITKEEP, serve the files.
|
|
binFS, err := fs.Sub(siteFS, "bin")
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("site fs sub dir failed: %w", err)
|
|
}
|
|
return http.FS(binFS), nil, nil
|
|
}
|
|
|
|
// Nothing we can do, serve the cache directory, thus allowing
|
|
// binaries to be placed there.
|
|
binFS, err := mkdest()
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("mkdest failed: %w", err)
|
|
}
|
|
return binFS, map[string]string{}, nil
|
|
}
|
|
return nil, nil, xerrors.Errorf("open coder binary archive failed: %w", err)
|
|
}
|
|
defer archive.Close()
|
|
|
|
binFS, err := mkdest()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
shaFiles, err := parseSHA1(siteFS)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("parse sha1 file failed: %w", err)
|
|
}
|
|
|
|
ok, err := verifyBinSha1IsCurrent(dest, siteFS, shaFiles)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("verify coder binaries sha1 failed: %w", err)
|
|
}
|
|
if !ok {
|
|
n, err := extractBin(dest, archive)
|
|
if err != nil {
|
|
return nil, nil, xerrors.Errorf("extract coder binaries failed: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return nil, nil, xerrors.New("no files were extracted from coder binaries archive")
|
|
}
|
|
}
|
|
|
|
return binFS, shaFiles, nil
|
|
}
|
|
|
|
func filterFiles(files []fs.DirEntry, names ...string) []fs.DirEntry {
|
|
var filtered []fs.DirEntry
|
|
for _, f := range files {
|
|
if slices.Contains(names, f.Name()) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, f)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// errHashMismatch is a sentinel error used in verifyBinSha1IsCurrent.
|
|
var errHashMismatch = xerrors.New("hash mismatch")
|
|
|
|
func parseSHA1(siteFS fs.FS) (map[string]string, error) {
|
|
b, err := fs.ReadFile(siteFS, "bin/coder.sha1")
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
|
|
}
|
|
|
|
shaFiles := make(map[string]string)
|
|
for _, line := range bytes.Split(bytes.TrimSpace(b), []byte{'\n'}) {
|
|
parts := bytes.Split(line, []byte{' ', '*'})
|
|
if len(parts) != 2 {
|
|
return nil, xerrors.Errorf("malformed sha1 file: %w", err)
|
|
}
|
|
shaFiles[string(parts[1])] = strings.ToLower(string(parts[0]))
|
|
}
|
|
if len(shaFiles) == 0 {
|
|
return nil, xerrors.Errorf("empty sha1 file: %w", err)
|
|
}
|
|
|
|
return shaFiles, nil
|
|
}
|
|
|
|
func verifyBinSha1IsCurrent(dest string, siteFS fs.FS, shaFiles map[string]string) (ok bool, err error) {
|
|
b1, err := fs.ReadFile(siteFS, "bin/coder.sha1")
|
|
if err != nil {
|
|
return false, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
|
|
}
|
|
b2, err := os.ReadFile(filepath.Join(dest, "coder.sha1"))
|
|
if err != nil {
|
|
if xerrors.Is(err, fs.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, xerrors.Errorf("read coder sha1 failed: %w", err)
|
|
}
|
|
|
|
// Check shasum files for equality for early-exit.
|
|
if !bytes.Equal(b1, b2) {
|
|
return false, nil
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
// Speed up startup by verifying files concurrently. Concurrency
|
|
// is limited to save resources / early-exit. Early-exit speed
|
|
// could be improved by using a context aware io.Reader and
|
|
// passing the context from errgroup.WithContext.
|
|
eg.SetLimit(3)
|
|
|
|
// Verify the hash of each on-disk binary.
|
|
for file, hash1 := range shaFiles {
|
|
file := file
|
|
hash1 := hash1
|
|
eg.Go(func() error {
|
|
hash2, err := sha1HashFile(filepath.Join(dest, file))
|
|
if err != nil {
|
|
if xerrors.Is(err, fs.ErrNotExist) {
|
|
return errHashMismatch
|
|
}
|
|
return xerrors.Errorf("hash file failed: %w", err)
|
|
}
|
|
if !strings.EqualFold(hash1, hash2) {
|
|
return errHashMismatch
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
if xerrors.Is(err, errHashMismatch) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// sha1HashFile computes a SHA1 hash of the file, returning the hex
|
|
// representation.
|
|
func sha1HashFile(name string) (string, error) {
|
|
//#nosec // Not used for cryptography.
|
|
hash := sha1.New()
|
|
f, err := os.Open(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
_, err = io.Copy(hash, f)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
b := make([]byte, hash.Size())
|
|
hash.Sum(b[:0])
|
|
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func extractBin(dest string, r io.Reader) (numExtracted int, err error) {
|
|
opts := []zstd.DOption{
|
|
// Concurrency doesn't help us when decoding the tar and
|
|
// can actually slow us down.
|
|
zstd.WithDecoderConcurrency(1),
|
|
// Ignoring checksums can give a slight performance
|
|
// boost but it's probably not worth the reduced safety.
|
|
zstd.IgnoreChecksum(false),
|
|
// Allow the decoder to use more memory giving us a 2-3x
|
|
// performance boost.
|
|
zstd.WithDecoderLowmem(false),
|
|
}
|
|
zr, err := zstd.NewReader(r, opts...)
|
|
if err != nil {
|
|
return 0, xerrors.Errorf("open zstd archive failed: %w", err)
|
|
}
|
|
defer zr.Close()
|
|
|
|
tr := tar.NewReader(zr)
|
|
n := 0
|
|
for {
|
|
h, err := tr.Next()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return n, nil
|
|
}
|
|
return n, xerrors.Errorf("read tar archive failed: %w", err)
|
|
}
|
|
if h.Name == "." || strings.Contains(h.Name, "..") {
|
|
continue
|
|
}
|
|
|
|
name := filepath.Join(dest, filepath.Base(h.Name))
|
|
f, err := os.Create(name)
|
|
if err != nil {
|
|
return n, xerrors.Errorf("create file failed: %w", err)
|
|
}
|
|
//#nosec // We created this tar, no risk of decompression bomb.
|
|
_, err = io.Copy(f, tr)
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return n, xerrors.Errorf("write file contents failed: %w", err)
|
|
}
|
|
err = f.Close()
|
|
if err != nil {
|
|
return n, xerrors.Errorf("close file failed: %w", err)
|
|
}
|
|
|
|
n++
|
|
}
|
|
}
|
|
|
|
// ErrorPageData contains the variables that are found in
|
|
// site/static/error.html.
|
|
type ErrorPageData struct {
|
|
Status int
|
|
// HideStatus will remove the status code from the page.
|
|
HideStatus bool
|
|
Title string
|
|
Description string
|
|
RetryEnabled bool
|
|
DashboardURL string
|
|
}
|
|
|
|
// RenderStaticErrorPage renders the static error page. This is used by app
|
|
// requests to avoid dependence on the dashboard but maintain the ability to
|
|
// render a friendly error page on subdomains.
|
|
func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPageData) {
|
|
type outerData struct {
|
|
Error ErrorPageData
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
rw.WriteHeader(data.Status)
|
|
|
|
err := errorTemplate.Execute(rw, outerData{Error: data})
|
|
if err != nil {
|
|
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to render error page: " + err.Error(),
|
|
Detail: fmt.Sprintf("Original error was: %d %s, %s", data.Status, data.Title, data.Description),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
type binHashCache struct {
|
|
binFS http.FileSystem
|
|
|
|
hashes map[string]string
|
|
mut sync.RWMutex
|
|
sf singleflight.Group
|
|
sem chan struct{}
|
|
}
|
|
|
|
func newBinHashCache(binFS http.FileSystem, binHashes map[string]string) *binHashCache {
|
|
b := &binHashCache{
|
|
binFS: binFS,
|
|
hashes: make(map[string]string, len(binHashes)),
|
|
mut: sync.RWMutex{},
|
|
sf: singleflight.Group{},
|
|
sem: make(chan struct{}, 4),
|
|
}
|
|
// Make a copy since we're gonna be mutating it.
|
|
for k, v := range binHashes {
|
|
b.hashes[k] = v
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
func (b *binHashCache) getHash(name string) (string, error) {
|
|
b.mut.RLock()
|
|
hash, ok := b.hashes[name]
|
|
b.mut.RUnlock()
|
|
if ok {
|
|
return hash, nil
|
|
}
|
|
|
|
// Avoid DOS by using a pool, and only doing work once per file.
|
|
v, err, _ := b.sf.Do(name, func() (interface{}, error) {
|
|
b.sem <- struct{}{}
|
|
defer func() { <-b.sem }()
|
|
|
|
f, err := b.binFS.Open(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
h := sha1.New() //#nosec // Not used for cryptography.
|
|
_, err = io.Copy(h, f)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
hash := hex.EncodeToString(h.Sum(nil))
|
|
b.mut.Lock()
|
|
b.hashes[name] = hash
|
|
b.mut.Unlock()
|
|
return hash, nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
//nolint:forcetypeassert
|
|
return strings.ToLower(v.(string)), nil
|
|
}
|