Files
rclone_gui/webgui/webgui.go
T
2026-06-20 13:51:06 +08:00

327 lines
9.8 KiB
Go

// 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 /<remote>:<path>.
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=<rcURL> 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()
}