diff --git a/client/web/qnap.go b/client/web/qnap.go index d3b1d8dd7..b3c0879c1 100644 --- a/client/web/qnap.go +++ b/client/web/qnap.go @@ -16,6 +16,8 @@ import ( "net/url" ) +const qnapPrefix = "/cgi-bin/qpkg/Tailscale/index.cgi/" + // authorizeQNAP authenticates the logged-in QNAP user and verifies // that they are authorized to use the web client. It returns true if the // request was handled and no further processing is required. diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 2369b4d57..21470c766 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -36,7 +36,7 @@ export default function useNodeData() { const [isPosting, setIsPosting] = useState(false) const fetchNodeData = useCallback(() => { - apiFetch("/api/data") + apiFetch("api/data") .then((r) => r.json()) .then((d) => setData(d)) .catch((error) => console.error(error)) diff --git a/client/web/synology.go b/client/web/synology.go index 7c3f82c11..70a024ace 100644 --- a/client/web/synology.go +++ b/client/web/synology.go @@ -15,6 +15,8 @@ import ( "tailscale.com/util/groupmember" ) +const synologyPrefix = "/webman/3rdparty/Tailscale/index.cgi/" + // authorizeSynology authenticates the logged-in Synology user and verifies // that they are authorized to use the web client. It returns true if the // request was handled and no further processing is required. diff --git a/client/web/vite.config.ts b/client/web/vite.config.ts index b36a5c287..6677195b1 100644 --- a/client/web/vite.config.ts +++ b/client/web/vite.config.ts @@ -20,7 +20,7 @@ filteringLogger.info = (...args) => { // https://vitejs.dev/config/ export default defineConfig({ - base: "/", + base: "./", plugins: [ paths(), svgr(), diff --git a/client/web/web.go b/client/web/web.go index a6e3ef589..2a2313a67 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -54,6 +54,7 @@ type Server struct { devProxy *httputil.ReverseProxy // only filled when devMode is on cgiMode bool + cgiPath string apiHandler http.Handler // csrf-protected api handler } @@ -64,6 +65,9 @@ type ServerOpts struct { // CGIMode indicates if the server is running as a CGI script. CGIMode bool + // If running in CGIMode, CGIPath is the URL path prefix to the CGI script. + CGIPath string + // LocalClient is the tailscale.LocalClient to use for this web server. // If nil, a new one will be created. LocalClient *tailscale.LocalClient @@ -78,6 +82,7 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) { devMode: opts.DevMode, lc: opts.LocalClient, cgiMode: opts.CGIMode, + cgiPath: opts.CGIPath, } cleanup = func() {} if s.devMode { @@ -115,7 +120,25 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - s.serve(w, r) + handler := s.serve + + // if running in cgi mode, strip the cgi path prefix + if s.cgiMode { + prefix := s.cgiPath + if prefix == "" { + switch distro.Get() { + case distro.Synology: + prefix = synologyPrefix + case distro.QNAP: + prefix = qnapPrefix + } + } + if prefix != "" { + handler = enforcePrefix(prefix, handler) + } + } + + handler(w, r) } func (s *Server) serve(w http.ResponseWriter, r *http.Request) { @@ -393,3 +416,17 @@ func (s *Server) csrfKey() []byte { return key } + +// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests, +// then strips it before invoking h. +// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present. +// Instead, it returns a redirect to the prefix path. +func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, prefix) { + http.Redirect(w, r, prefix, http.StatusFound) + return + } + http.StripPrefix(prefix, h).ServeHTTP(w, r) + } +}