327 lines
9.8 KiB
Go
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()
|
|
}
|