diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bca29fcb2..a5dda217a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -230,6 +230,9 @@ jobs: go-version: '~1.26.0' check-latest: true cache: false + + - name: Fetch GUI dist + run: make fetch-gui - name: Cache uses: actions/cache@v5 diff --git a/.gitignore b/.gitignore index 70efa8580..728b6832a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ __pycache__ .DS_Store resource_windows_*.syso .devcontainer +# GUI dist is fetched at build time via `make fetch-gui` +cmd/gui/dist/ \ No newline at end of file diff --git a/Makefile b/Makefile index 393cf2f5f..3541f2461 100644 --- a/Makefile +++ b/Makefile @@ -45,9 +45,9 @@ LINTTAGS=--build-tags "$(GOTAGS)" endif LDFLAGS=--ldflags "-s -X github.com/rclone/rclone/fs.Version=$(TAG)" -.PHONY: rclone test_all vars version +.PHONY: rclone test_all vars version fetch-gui -rclone: +rclone: fetch-gui ifeq ($(GO_OS),windows) go run bin/resource_windows.go -version $(TAG) -syso resource_windows_`go env GOARCH`.syso endif @@ -59,6 +59,9 @@ endif cp -av rclone`go env GOEXE` `go env GOPATH`/bin/rclone`go env GOEXE`.new mv -v `go env GOPATH`/bin/rclone`go env GOEXE`.new `go env GOPATH`/bin/rclone`go env GOEXE` +fetch-gui: + $(SHELL) ./bin/fetch-gui-dist.sh + test_all: go install $(LDFLAGS) $(BUILDTAGS) $(BUILD_ARGS) github.com/rclone/rclone/fstest/test_all diff --git a/bin/fetch-gui-dist.sh b/bin/fetch-gui-dist.sh new file mode 100755 index 000000000..3460f3727 --- /dev/null +++ b/bin/fetch-gui-dist.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Fetch the latest GUI dist from rclone/rclone-web GitHub releases. +# +# Downloads dist.zip from the latest release and extracts it to +# cmd/gui/dist/. Skips the download if the local tag matches. +# +# Requires: curl, unzip + +set -euo pipefail + +REPO="rclone/rclone-web" +DEST="cmd/gui/dist" +TAG_FILE="${DEST}/.tag" + +CURL_OPTS=(-fSs --retry 5 --retry-delay 2 --retry-all-errors) + +# Get the latest release info +echo "Checking latest release of ${REPO}..." +RELEASE_JSON=$(curl "${CURL_OPTS[@]}" \ + "https://api.github.com/repos/${REPO}/releases/latest") || { + echo "Error: failed to fetch release info from GitHub API" >&2 + exit 1 +} + +TAG=$(echo "${RELEASE_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") +ASSET_URL=$(echo "${RELEASE_JSON}" | python3 -c " +import sys, json +r = json.load(sys.stdin) +for a in r['assets']: + if a['name'] == 'dist.zip': + print(a['browser_download_url']) + sys.exit(0) +print('', file=sys.stderr) +sys.exit(1) +") || { + echo "Error: dist.zip asset not found in release ${TAG}" >&2 + exit 1 +} + +echo "Latest release: ${TAG}" + +# Check if we already have this version +if [ -f "${TAG_FILE}" ] && [ "$(cat "${TAG_FILE}")" = "${TAG}" ]; then + echo "Already up to date (${TAG})" + exit 0 +fi + +# Download dist.zip +TMPFILE=$(mktemp /tmp/rclone-gui-dist.XXXXXX.zip) +trap 'rm -f "${TMPFILE}"' EXIT + +echo "Downloading dist.zip from ${TAG}..." +curl -L "${CURL_OPTS[@]}" -o "${TMPFILE}" "${ASSET_URL}" || { + echo "Error: failed to download dist.zip" >&2 + exit 1 +} + +# Extract +echo "Extracting to ${DEST}/..." +rm -rf "${DEST}" +mkdir -p "${DEST}" +unzip -q "${TMPFILE}" -d "${DEST}" + +# Write tag for cache comparison +echo -n "${TAG}" > "${TAG_FILE}" + +echo "Done. GUI dist updated to ${TAG}" diff --git a/cmd/all/all.go b/cmd/all/all.go index 0f2b94ce9..912cde629 100644 --- a/cmd/all/all.go +++ b/cmd/all/all.go @@ -31,6 +31,7 @@ import ( _ "github.com/rclone/rclone/cmd/genautocomplete" _ "github.com/rclone/rclone/cmd/gendocs" _ "github.com/rclone/rclone/cmd/gitannex" + _ "github.com/rclone/rclone/cmd/gui" _ "github.com/rclone/rclone/cmd/hashsum" _ "github.com/rclone/rclone/cmd/link" _ "github.com/rclone/rclone/cmd/listremotes" diff --git a/cmd/gui/gui.go b/cmd/gui/gui.go new file mode 100644 index 000000000..17eb83c78 --- /dev/null +++ b/cmd/gui/gui.go @@ -0,0 +1,258 @@ +// Package gui implements the "rclone gui" command. +package gui + +import ( + "context" + "embed" + "flag" + "fmt" + iofs "io/fs" + "net" + "net/http" + "net/url" + "strings" + + "github.com/rclone/rclone/cmd" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/rc" + "github.com/rclone/rclone/fs/rc/rcserver" + libhttp "github.com/rclone/rclone/lib/http" + "github.com/rclone/rclone/lib/random" + "github.com/rclone/rclone/lib/systemd" + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" +) + +//go:embed dist +var assets embed.FS + +var ( + guiAddr []string + apiAddr []string + user string + pass string + noAuth bool + noOpenBrowser bool + enableMetrics bool +) + +func init() { + cmd.Root.AddCommand(commandDefinition) + f := commandDefinition.Flags() + f.StringArrayVar(&guiAddr, "addr", nil, "IPaddress:Port for the GUI server (default auto-chosen localhost port)") + f.StringArrayVar(&apiAddr, "api-addr", nil, "IPaddress:Port for the RC API server (default auto-chosen localhost port)") + f.StringVar(&user, "user", "", "User name for RC authentication") + f.StringVar(&pass, "pass", "", "Password for RC authentication") + f.BoolVar(&noAuth, "no-auth", false, "Don't require auth for the RC API") + f.BoolVar(&noOpenBrowser, "no-open-browser", false, "Skip opening the browser automatically") + f.BoolVar(&enableMetrics, "enable-metrics", false, "Enable OpenMetrics/Prometheus compatible endpoint at /metrics") +} + +var commandDefinition = &cobra.Command{ + Use: "gui", + Short: `Open the web based GUI.`, + Long: `This command starts an embedded web GUI for rclone and opens it in +your default browser. + +It starts an RC API server and a GUI server on separate localhost +ports, generates login credentials automatically unless --no-auth +is specified, and opens the browser already authenticated. + + rclone gui + +Use --no-open-browser to skip opening the browser automatically: + + rclone gui --no-open-browser + +Use --addr to bind the GUI to a specific address: + + rclone gui --addr localhost:5580 + +Use --user and --pass to set specific credentials: + + rclone gui --user admin --pass secret + +Use --no-auth to disable authentication entirely: + + rclone gui --no-auth +`, + Annotations: map[string]string{ + "versionIntroduced": "v1.74", + "groups": "RC", + }, + Run: func(command *cobra.Command, args []string) { + cmd.CheckArgs(0, 0, command, args) + ctx := context.Background() + + // --- 1. Create the GUI server (binds port eagerly, before Serve) --- + guiCfg := libhttp.DefaultCfg() + if command.Flags().Changed("addr") { + guiCfg.ListenAddr = guiAddr + } else { + guiCfg.ListenAddr = []string{"localhost:0"} + } + guiServer, err := libhttp.NewServer(ctx, libhttp.WithConfig(guiCfg)) + if err != nil { + fs.Fatalf(nil, "Failed to create GUI server: %v", err) + } + + // Read the GUI origin from the bound address (available before Serve). + guiOrigin := originFromURL(guiServer.URLs()[0]) + + // --- 2. Configure the RC API server --- + opt := rc.Opt // copy global defaults + opt.Enabled = true + opt.WebUI = false + opt.Serve = false + + if command.Flags().Changed("api-addr") { + opt.HTTP.ListenAddr = apiAddr + } else { + port, err := freePort() + if err != nil { + fs.Fatalf(nil, "Failed to find a free port for RC: %v", err) + } + opt.HTTP.ListenAddr = []string{fmt.Sprintf("localhost:%d", port)} + } + + // CORS: allow the GUI origin to make cross-port API requests. + opt.HTTP.AllowOrigin = guiOrigin + + // Forward metrics flag to the RC server. + if command.Flags().Changed("enable-metrics") { + opt.EnableMetrics = enableMetrics + } + + // --- 3. Generate credentials if needed --- + if command.Flags().Changed("user") { + opt.Auth.BasicUser = user + } + if command.Flags().Changed("pass") { + opt.Auth.BasicPass = pass + } + if command.Flags().Changed("no-auth") { + opt.NoAuth = noAuth + } + + if !opt.NoAuth { + if opt.Auth.BasicUser == "" { + opt.Auth.BasicUser = "gui" + fs.Infof(nil, "No username specified. Using default username: %s", opt.Auth.BasicUser) + } + if opt.Auth.BasicPass == "" { + randomPass, err := random.Password(128) + if err != nil { + fs.Fatalf(nil, "Failed to make password: %v", err) + } + opt.Auth.BasicPass = randomPass + fs.Infof(nil, "No password specified. Using random password: %s", randomPass) + } + } + + // --- 4. Start the RC server (unchanged rcserver.Start) --- + rcServer, err := rcserver.Start(ctx, &opt) + if err != nil { + fs.Fatalf(nil, "Failed to start RC server: %v", err) + } + if rcServer == nil { + fs.Fatal(nil, "RC server not configured") + } + + // Build the RC URL from the address we configured (rcserver.Server + // does not expose URLs, and we know the address we passed in). + rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/" + + // --- 5. Mount the embedded GUI handler and start serving --- + spaHandler := guiHandler() + guiServer.Router().Get("/*", spaHandler.ServeHTTP) + guiServer.Router().Head("/*", spaHandler.ServeHTTP) + guiServer.Serve() + + guiURL := guiServer.URLs()[0] + fs.Logf(nil, "Serving GUI on %s", guiURL) + + // --- 6. Open browser --- + loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) + + fs.Logf(nil, "GUI available at %s", loginURL) + if flag.Lookup("test.v") == nil && !noOpenBrowser { + if err := open.Start(loginURL); err != nil { + fs.Errorf(nil, "Failed to open GUI in browser: %v", err) + } + } + + // --- 7. Wait for either server to exit, then shut both down --- + defer systemd.Notify()() + done := make(chan struct{}, 2) + go func() { rcServer.Wait(); done <- struct{}{} }() + go func() { guiServer.Wait(); done <- struct{}{} }() + <-done + _ = rcServer.Shutdown() + _ = guiServer.Shutdown() + }, +} + +// freePort asks the OS for a free TCP port on localhost. +func freePort() (int, error) { + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + return 0, err + } + defer func() { _ = l.Close() }() + return l.Addr().(*net.TCPAddr).Port, nil +} + +// originFromURL extracts the origin (scheme://host) from a URL string, +// stripping any path or trailing slash. +func originFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return strings.TrimRight(rawURL, "/") + } + return u.Scheme + "://" + u.Host +} + +// guiHandler returns an http.Handler that serves the embedded GUI bundle +// with SPA fallback: paths that don't match a real file return index.html. +func guiHandler() http.Handler { + sub, err := iofs.Sub(assets, "dist") + if err != nil { + panic("gui: embedded dist missing: " + err.Error()) + } + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, err := iofs.Stat(sub, path); err == nil { + fileServer.ServeHTTP(w, r) + return + } + // SPA fallback: serve index.html for unknown paths so that + // client-side routing (e.g. /login) works. + r.URL.Path = "/" + fileServer.ServeHTTP(w, r) + }) +} + +// guiBaseURL is the GUI server's URL. rcURL is the RC API server's URL. +// When auth is enabled it appends url, user, and pass as query +// parameters so the React app can discover the API endpoint and +// log in automatically. +func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { + u, err := url.Parse(guiBaseURL) + if err != nil { + return guiBaseURL + } + if noAuth { + return u.String() + } + u.Path = "/login" + q := u.Query() + q.Set("url", rcURL) + q.Set("user", user) + q.Set("pass", pass) + u.RawQuery = q.Encode() + return u.String() +} diff --git a/cmd/gui/gui_test.go b/cmd/gui/gui_test.go new file mode 100644 index 000000000..2d99704e0 --- /dev/null +++ b/cmd/gui/gui_test.go @@ -0,0 +1,155 @@ +package gui + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildLoginURL(t *testing.T) { + tests := []struct { + name string + guiURL string + rcURL string + user string + pass string + noAuth bool + want string + }{ + { + name: "with credentials", + guiURL: "http://localhost:5580/", + rcURL: "http://localhost:5572/", + user: "gui", + pass: "secret", + noAuth: false, + want: "http://localhost:5580/login?pass=secret&url=http%3A%2F%2Flocalhost%3A5572%2F&user=gui", + }, + { + name: "no auth", + guiURL: "http://localhost:5580/", + rcURL: "http://localhost:5572/", + user: "", + pass: "", + noAuth: true, + want: "http://localhost:5580/", + }, + { + name: "no auth ignores credentials", + guiURL: "http://localhost:5580/", + rcURL: "http://localhost:5572/", + user: "gui", + pass: "secret", + noAuth: true, + want: "http://localhost:5580/", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildLoginURL(tt.guiURL, tt.rcURL, tt.user, tt.pass, tt.noAuth) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestOriginFromURL(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "with trailing slash", + url: "http://localhost:5580/", + want: "http://localhost:5580", + }, + { + name: "with path", + url: "http://localhost:5580/some/path", + want: "http://localhost:5580", + }, + { + name: "no trailing slash", + url: "http://localhost:5580", + want: "http://localhost:5580", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := originFromURL(tt.url) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFreePort(t *testing.T) { + port, err := freePort() + assert.NoError(t, err) + assert.Greater(t, port, 0) + assert.Less(t, port, 65536) +} + +func TestHandlerServesIndexHTML(t *testing.T) { + h := guiHandler() + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + resp := w.Result() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, string(body), "
") +} + +func TestHandlerServesStaticAssets(t *testing.T) { + h := guiHandler() + req := httptest.NewRequest("GET", "/icon.svg", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.True(t, strings.Contains(string(body), "", + "SPA fallback should serve index.html for unknown routes") +} + +func TestHandlerSPAFallbackDeepPath(t *testing.T) { + h := guiHandler() + + req := httptest.NewRequest("GET", "/some/deep/route", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + resp := w.Result() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, string(body), "
") +}