mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-10 12:21:53 -04:00
GET / in the client/web assets handler normalized to an empty fs path. The empty name is invalid, and file systems wrapped in fs.Sub reject it with fs.ErrInvalid instead of fs.ErrNotExist. The index.html fallback only triggers on fs.ErrNotExist, so it never ran and the request failed with a 500 "internal error". This broke the corp smoke test that curls http://100.100.100.100/. Normalize the empty path to index.html before serving. Also make compserve.ServeFile return fs.ErrNotExist for invalid paths, per its documented contract, instead of leaking fs.ErrInvalid. Both layers have regression tests verified to fail without the fix. Updates tailscale/corp#20099 Updates #12170 Signed-off-by: James Tucker <james@tailscale.com>
124 lines
4.0 KiB
Go
124 lines
4.0 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package web
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
prebuilt "github.com/tailscale/web-client-prebuilt"
|
|
"tailscale.com/tsweb/compserve"
|
|
"tailscale.com/tsweb/vcstime"
|
|
)
|
|
|
|
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
|
if devMode {
|
|
// When in dev mode, proxy asset requests to the Vite dev server.
|
|
cleanup := startDevServer()
|
|
return devServerProxy(), cleanup
|
|
}
|
|
|
|
// vcstime supplies the VCS commit time as mod time for the embedded
|
|
// assets, enabling conditional requests.
|
|
fsys := vcstime.FS(prebuilt.FS())
|
|
opts := compserve.Options{}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
if path == "" {
|
|
// The root path is the SPA entrypoint.
|
|
path = "index.html"
|
|
}
|
|
if strings.HasPrefix(path, "assets/") {
|
|
// Aggressively cache static assets, since we cache-bust our assets with
|
|
// hashed filenames.
|
|
w.Header().Set("Cache-Control", "public, max-age=31535996")
|
|
}
|
|
err := compserve.ServeFile(w, r, fsys, path, opts)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
// Rewrite request to just fetch index.html and let
|
|
// the frontend router handle it. The fallback must not inherit
|
|
// cache headers of the original path; nothing has been written
|
|
// yet, so they can still be adjusted.
|
|
w.Header().Del("Cache-Control")
|
|
err = compserve.ServeFile(w, r, fsys, "index.html", opts)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// Errors may embed the request path; log rather than reflect it.
|
|
log.Printf("web client: serving %q: %v", path, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}), nil
|
|
}
|
|
|
|
// startDevServer starts the JS dev server that does on-demand rebuilding
|
|
// and serving of web client JS and CSS resources.
|
|
func startDevServer() (cleanup func()) {
|
|
root := gitRootDir()
|
|
webClientPath := filepath.Join(root, "client", "web")
|
|
|
|
yarn := filepath.Join(root, "tool", "yarn")
|
|
node := filepath.Join(root, "tool", "node")
|
|
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
|
|
|
|
log.Printf("installing JavaScript deps using %s...", yarn)
|
|
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
|
|
if err != nil {
|
|
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
|
|
}
|
|
log.Printf("starting JavaScript dev server...")
|
|
cmd := exec.Command(node, vite)
|
|
cmd.Dir = webClientPath
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
log.Fatalf("Starting JS dev server: %v", err)
|
|
}
|
|
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
|
|
return func() {
|
|
cmd.Process.Signal(os.Interrupt)
|
|
err := cmd.Wait()
|
|
log.Printf("JavaScript dev server exited: %v", err)
|
|
}
|
|
}
|
|
|
|
// devServerProxy returns a reverse proxy to the vite dev server.
|
|
func devServerProxy() *httputil.ReverseProxy {
|
|
// We use Vite to develop on the web client.
|
|
// Vite starts up its own local server for development,
|
|
// which we proxy requests to from Server.ServeHTTP.
|
|
// Here we set up the proxy to Vite's server.
|
|
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
w.Write([]byte("The web client development server isn't running. " +
|
|
"Run `./tool/yarn --cwd client/web start` from " +
|
|
"the repo root to start the development server."))
|
|
w.Write([]byte("\n\nError: " + err.Error()))
|
|
}
|
|
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
|
|
devProxy := httputil.NewSingleHostReverseProxy(viteTarget)
|
|
devProxy.ErrorHandler = handleErr
|
|
return devProxy
|
|
}
|
|
|
|
func gitRootDir() string {
|
|
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
|
if err != nil {
|
|
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
|
|
}
|
|
return strings.TrimSpace(string(top))
|
|
}
|