// Package webgui implements the "rclone webgui" command — an in-process // web GUI for rclone with the Anthropic design system. It mirrors the // architecture of cmd/gui/gui.go (two in-process HTTP servers: a static // GUI server and an RC API server, with the browser opened automatically), // but serves our own embedded vanilla HTML/CSS/JS frontend instead of // the upstream React bundle. package webgui import ( "context" _ "embed" "fmt" iofs "io/fs" "net/http" "net/url" "os" "strings" "sync" "github.com/go-chi/chi/v5/middleware" "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 web var embedFS iofs.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: "webgui [path]", Short: `Open the web based GUI.`, Long: `This command starts an embedded web GUI for rclone and opens it in your default browser. Two localhost ports are bound: one serves the static GUI, the other is the rclone RC API server that the GUI talks to. Credentials are generated automatically unless --no-auth is specified. rclone webgui By default ` + "`rclone webgui`" + ` serves the GUI embedded into the rclone binary at build time. You can override this by passing a path to an unpacked GUI directory, which is useful for iterating on the frontend without rebuilding rclone: rclone webgui ./cmd/webgui/web Use --no-open-browser to skip opening the browser automatically: rclone webgui --no-open-browser Use --addr to bind the GUI to a specific address: rclone webgui --addr localhost:5580 Use --user and --pass to set specific credentials: rclone webgui --user admin --pass secret Use --no-auth to disable authentication entirely (localhost only): rclone webgui --no-auth Note: --no-auth enables the RC API's --rc-serve mode, which exposes an HTTP fileserver on every configured remote. Only run this on a trusted network. `, Annotations: map[string]string{ "versionIntroduced": "v1.75", "groups": "RC", }, RunE: func(command *cobra.Command, args []string) error { cmd.CheckArgs(0, 1, command, args) ctx := context.Background() // Resolve the GUI source (embedded subtree or local directory) // before binding any sockets so errors surface immediately. var srcPath string if len(args) == 1 { srcPath = args[0] } srcFS, err := guiSourceFS(srcPath) if err != nil { return err } // 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 { return fmt.Errorf("failed to create GUI server: %w", err) } // Read the GUI origin from the bound address (available before Serve). guiOrigin := originFromURL(guiServer.URLs()[0]) // Configure the RC API server opt := rc.Opt // copy global defaults opt.Enabled = true opt.WebUI = false // opt.Serve = true exposes an HTTP fileserver on every configured // remote so the GUI can download files via GET /:. opt.Serve = true if command.Flags().Changed("api-addr") { opt.HTTP.ListenAddr = apiAddr } else { opt.HTTP.ListenAddr = []string{"localhost:0"} } // 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 } // Auth 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 { return fmt.Errorf("failed to make password: %w", err) } opt.Auth.BasicPass = randomPass fs.Infof(nil, "No password specified. Using random password for this session") } } // Start the RC server rcServer, err := rcserver.Start(ctx, &opt) if err != nil || rcServer == nil { return fmt.Errorf("failed to start RC server: %w", err) } // Read the bound RC URL back from rcserver, in case we asked // libhttp to pick a free port (localhost:0). rcURL := rcServer.URLs()[0] // Mount the GUI handler and start serving spaHandler := guiHandler(srcFS) guiServer.Router().Use(middleware.Compress(5)) guiServer.Router().Get("/*", spaHandler.ServeHTTP) guiServer.Router().Head("/*", spaHandler.ServeHTTP) guiServer.Serve() guiURL := guiServer.URLs()[0] guiSource := "embedded bundle" if srcPath != "" { guiSource = fmt.Sprintf("from %s", srcPath) } fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) // Build the launch URL: always pass ?url= so the SPA can // discover the RC base. loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) fs.Logf(nil, "GUI available at %s", safeLoginURL(loginURL)) if !opt.NoAuth { fs.Logf(nil, "GUI authentication user: %s", opt.Auth.BasicUser) } if !noOpenBrowser { if err := open.Start(loginURL); err != nil { fs.Errorf(nil, "failed to open GUI in browser: %v", err) } } // Wait for either server to exit, then shut both down and // join the second goroutine before returning. defer systemd.Notify()() var wg sync.WaitGroup done := make(chan struct{}, 2) wg.Add(2) go func() { defer wg.Done(); rcServer.Wait(); done <- struct{}{} }() go func() { defer wg.Done(); guiServer.Wait(); done <- struct{}{} }() <-done _ = rcServer.Shutdown() _ = guiServer.Shutdown() wg.Wait() return 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 } // guiSourceFS opens the GUI bundle at the given path. An empty path // returns the embedded bundle (the `web/` directory compiled into the // binary). A non-empty path must be an existing directory whose contents // are served directly — useful for hot-reload during development. func guiSourceFS(path string) (iofs.FS, error) { if path == "" { sub, err := iofs.Sub(embedFS, "web") if err != nil { return nil, fmt.Errorf("failed to read embedded GUI: %w", err) } if _, err := iofs.Stat(sub, "index.html"); err != nil { return nil, fmt.Errorf("embedded GUI has no index.html: %w", err) } return sub, nil } info, err := os.Stat(path) if err != nil { return nil, fmt.Errorf("failed to stat GUI source %q: %w", path, err) } if !info.IsDir() { return nil, fmt.Errorf("GUI source must be a directory: %q", path) } if _, err := os.Stat(path + "/index.html"); err != nil { return nil, fmt.Errorf("GUI source directory has no index.html: %w", err) } return os.DirFS(path), nil } // guiHandler returns an http.Handler that serves the GUI bundle from // srcFS with SPA fallback: paths that don't match a real file return // index.html so client-side hash routing keeps working. func guiHandler(srcFS iofs.FS) http.Handler { fileServer := http.FileServer(http.FS(srcFS)) 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(srcFS, 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) }) } // buildLoginURL constructs the URL the browser should open. The query // string always carries the RC API base URL so the SPA can find it. // When auth is enabled, user/pass and a /login hash are added so the SPA can // present credentials to the cross-port RC server, then remove them from the // visible address bar on load. func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { u, err := url.Parse(guiBaseURL) if err != nil { return guiBaseURL } q := u.Query() q.Set("url", rcURL) if !noAuth { u.Path = "/login" q.Set("user", user) q.Set("pass", pass) } // Always land on the remotes view. if u.Fragment == "" { u.Fragment = "/remotes" } u.RawQuery = q.Encode() return u.String() } func safeLoginURL(loginURL string) string { u, err := url.Parse(loginURL) if err != nil { return loginURL } q := u.Query() q.Del("user") q.Del("pass") u.RawQuery = q.Encode() return u.String() }