diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index f2e7b1ed1..c7ca64703 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1980,7 +1980,6 @@ func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) { slices.Sort(ports) uniq.ModifySlice(&ports) - b.logf("localbackend: handling TCP ports = %v", ports) var f func(uint16) bool switch len(ports) { case 0: diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index afb08df8c..b12100432 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -15,9 +15,12 @@ import ( "net/http/httputil" "net/netip" "net/url" + "os" + "path" pathpkg "path" "strconv" "strings" + "sync" "time" "tailscale.com/ipn" @@ -151,37 +154,44 @@ func (b *LocalBackend) HandleInterceptedTCPConn(dport uint16, srcAddr netip.Addr sendRST() } -func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView, ok bool) { +func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView, at string, ok bool) { var z ipn.HTTPHandlerView // zero value if r.TLS == nil { - return z, false + return z, "", false } sctx, ok := r.Context().Value(serveHTTPContextKey{}).(*serveHTTPContext) if !ok { b.logf("[unexpected] localbackend: no serveHTTPContext in request") - return z, false + return z, "", false } wsc, ok := b.webServerConfig(r.TLS.ServerName, sctx.DestPort) if !ok { - return z, false + return z, "", false } - path := r.URL.Path + if h, ok := wsc.Handlers().GetOk(r.URL.Path); ok { + return h, r.URL.Path, true + } + path := path.Clean(r.URL.Path) for { + withSlash := path + "/" + if h, ok := wsc.Handlers().GetOk(withSlash); ok { + return h, withSlash, true + } if h, ok := wsc.Handlers().GetOk(path); ok { - return h, true + return h, path, true } if path == "/" { - return z, false + return z, "", false } path = pathpkg.Dir(path) } } func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { - h, ok := b.getServeHandler(r) + h, mountPoint, ok := b.getServeHandler(r) if !ok { http.NotFound(w, r) return @@ -192,7 +202,7 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { return } if v := h.Path(); v != "" { - io.WriteString(w, "TODO(bradfitz): serve file") + b.serveFileOrDirectory(w, r, v, mountPoint) return } if v := h.Proxy(); v != "" { @@ -219,6 +229,74 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "empty handler", 500) } +func (b *LocalBackend) serveFileOrDirectory(w http.ResponseWriter, r *http.Request, fileOrDir, mountPoint string) { + fi, err := os.Stat(fileOrDir) + if err != nil { + if os.IsNotExist(err) { + http.NotFound(w, r) + return + } + http.Error(w, err.Error(), 500) + return + } + if fi.Mode().IsRegular() { + if mountPoint != r.URL.Path { + http.NotFound(w, r) + return + } + f, err := os.Open(fileOrDir) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer f.Close() + http.ServeContent(w, r, path.Base(mountPoint), fi.ModTime(), f) + return + } + if !fi.IsDir() { + http.Error(w, "not a file or directory", 500) + return + } + if len(r.URL.Path) < len(mountPoint) && r.URL.Path+"/" == mountPoint { + http.Redirect(w, r, mountPoint, http.StatusFound) + return + } + + var fs http.Handler = http.FileServer(http.Dir(fileOrDir)) + if mountPoint != "/" { + fs = http.StripPrefix(strings.TrimSuffix(mountPoint, "/"), fs) + } + fs.ServeHTTP(&fixLocationHeaderResponseWriter{ + ResponseWriter: w, + mountPoint: mountPoint, + }, r) +} + +// fixLocationHeaderResponseWriter is an http.ResponseWriter wrapper that, upon +// flushing HTTP headers, prefixes any Location header with the mount point. +type fixLocationHeaderResponseWriter struct { + http.ResponseWriter + mountPoint string + fixOnce sync.Once // guards call to fix +} + +func (w *fixLocationHeaderResponseWriter) fix() { + h := w.ResponseWriter.Header() + if v := h.Get("Location"); v != "" { + h.Set("Location", w.mountPoint+v) + } +} + +func (w *fixLocationHeaderResponseWriter) WriteHeader(code int) { + w.fixOnce.Do(w.fix) + w.ResponseWriter.WriteHeader(code) +} + +func (w *fixLocationHeaderResponseWriter) Write(p []byte) (int, error) { + w.fixOnce.Do(w.fix) + return w.ResponseWriter.Write(p) +} + // expandProxyArg returns a URL from s, where s can be of form: // // * port number ("8080") diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go index bda5b434e..8bd90ee65 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/ipn/ipnlocal/serve_test.go @@ -4,7 +4,20 @@ package ipnlocal -import "testing" +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "tailscale.com/ipn" +) func TestExpandProxyArg(t *testing.T) { type res struct { @@ -31,3 +44,211 @@ func TestExpandProxyArg(t *testing.T) { } } } + +func TestGetServeHandler(t *testing.T) { + const serverName = "example.ts.net" + conf1 := &ipn.ServeConfig{ + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + serverName + ":443": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {}, + "/bar": {}, + "/foo/": {}, + "/foo/bar": {}, + "/foo/bar/": {}, + }, + }, + }, + } + + tests := []struct { + name string + port uint16 // or 443 is zero + path string // http.Request.URL.Path + conf *ipn.ServeConfig + want string // mountPoint + }{ + { + name: "nothing", + path: "/", + conf: nil, + want: "", + }, + { + name: "root", + conf: conf1, + path: "/", + want: "/", + }, + { + name: "root-other", + conf: conf1, + path: "/other", + want: "/", + }, + { + name: "bar", + conf: conf1, + path: "/bar", + want: "/bar", + }, + { + name: "foo-bar", + conf: conf1, + path: "/foo/bar", + want: "/foo/bar", + }, + { + name: "foo-bar-slash", + conf: conf1, + path: "/foo/bar/", + want: "/foo/bar/", + }, + { + name: "foo-bar-other", + conf: conf1, + path: "/foo/bar/other", + want: "/foo/bar/", + }, + { + name: "foo-other", + conf: conf1, + path: "/foo/other", + want: "/foo/", + }, + { + name: "foo-no-trailing-slash", + conf: conf1, + path: "/foo", + want: "/foo/", + }, + { + name: "dot-dots", + conf: conf1, + path: "/foo/../../../../../../../../etc/passwd", + want: "/", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &LocalBackend{ + serveConfig: tt.conf.View(), + logf: t.Logf, + } + req := &http.Request{ + URL: &url.URL{ + Path: tt.path, + }, + TLS: &tls.ConnectionState{ServerName: serverName}, + } + port := tt.port + if port == 0 { + port = 443 + } + req = req.WithContext(context.WithValue(req.Context(), serveHTTPContextKey{}, &serveHTTPContext{ + DestPort: port, + })) + + h, got, ok := b.getServeHandler(req) + if (got != "") != ok { + t.Fatalf("got ok=%v, but got mountPoint=%q", ok, got) + } + if h.Valid() != ok { + t.Fatalf("got ok=%v, but valid=%v", ok, h.Valid()) + } + if got != tt.want { + t.Errorf("got handler at mount %q, want %q", got, tt.want) + } + }) + } +} + +func TestServeFileOrDirectory(t *testing.T) { + td := t.TempDir() + writeFile := func(suffix, contents string) { + if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + writeFile("foo", "this is foo") + writeFile("bar", "this is bar") + os.MkdirAll(filepath.Join(td, "subdir"), 0700) + writeFile("subdir/file-a", "this is A") + writeFile("subdir/file-b", "this is B") + writeFile("subdir/file-c", "this is C") + + contains := func(subs ...string) func([]byte, *http.Response) error { + return func(resBody []byte, res *http.Response) error { + for _, sub := range subs { + if !bytes.Contains(resBody, []byte(sub)) { + return fmt.Errorf("response body does not contain %q: %s", sub, resBody) + } + } + return nil + } + } + isStatus := func(wantCode int) func([]byte, *http.Response) error { + return func(resBody []byte, res *http.Response) error { + if res.StatusCode != wantCode { + return fmt.Errorf("response status = %d; want %d", res.StatusCode, wantCode) + } + return nil + } + } + isRedirect := func(wantLocation string) func([]byte, *http.Response) error { + return func(resBody []byte, res *http.Response) error { + switch res.StatusCode { + case 301, 302, 303, 307, 308: + if got := res.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("got Location = %q; want %q", got, wantLocation) + } + default: + return fmt.Errorf("response status = %d; want redirect. body: %s", res.StatusCode, resBody) + } + return nil + } + } + + b := &LocalBackend{} + + tests := []struct { + req string + mount string + want func(resBody []byte, res *http.Response) error + }{ + // Mounted at / + + {"/", "/", contains("foo", "bar", "subdir")}, + {"/../../.../../../../../../../etc/passwd", "/", isStatus(404)}, + {"/foo", "/", contains("this is foo")}, + {"/bar", "/", contains("this is bar")}, + {"/bar/inside-file", "/", isStatus(404)}, + {"/subdir", "/", isRedirect("/subdir/")}, + {"/subdir/", "/", contains("file-a", "file-b", "file-c")}, + {"/subdir/file-a", "/", contains("this is A")}, + {"/subdir/file-z", "/", isStatus(404)}, + + {"/doc", "/doc/", isRedirect("/doc/")}, + {"/doc/", "/doc/", contains("foo", "bar", "subdir")}, + {"/doc/../../.../../../../../../../etc/passwd", "/doc/", isStatus(404)}, + {"/doc/foo", "/doc/", contains("this is foo")}, + {"/doc/bar", "/doc/", contains("this is bar")}, + {"/doc/bar/inside-file", "/doc/", isStatus(404)}, + {"/doc/subdir", "/doc/", isRedirect("/doc/subdir/")}, + {"/doc/subdir/", "/doc/", contains("file-a", "file-b", "file-c")}, + {"/doc/subdir/file-a", "/doc/", contains("this is A")}, + {"/doc/subdir/file-z", "/doc/", isStatus(404)}, + } + for _, tt := range tests { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", tt.req, nil) + b.serveFileOrDirectory(rec, req, td, tt.mount) + if tt.want == nil { + t.Errorf("no want for path %q", tt.req) + return + } + if err := tt.want(rec.Body.Bytes(), rec.Result()); err != nil { + t.Errorf("error for req %q (mount %v): %v", tt.req, tt.mount, err) + } + } +}