/
githubmirror
/
rclone
Обзор
Документация
Войти
/
githubmirror
/
rclone
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
cmd/gui/gui.go
342 строки
11 KB
Kyue
gui: respect explicit --rc-allow-origin instead of always deriving it from the bind address
23 июл 2026, 18:35
23 июл 2026, 18:35
7de6d61
Код
Авторство
О чём код?
// Package gui implements the "rclone gui" command. package gui import ( "archive/zip" "bytes" "context" _ "embed" "fmt" iofs "io/fs" "net" "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 dist.zip var distZip []byte //go:embed dist.tag var distTag string 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 [path]", Short: `Open the web based GUI.`, Long: `This command starts an embedded web GUI for rclone and opens it in your default browser. This 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 By default rclone gui serves the web GUI that was embedded into the rclone binary at build time from https://github.com/rclone/rclone-web/ You can override this by passing a path to either an unpacked GUI directory or a dist.zip archive (e.g. one downloaded from the rclone-web releases page): rclone gui ./my-dist/ rclone gui ./dist.zip This is useful for iterating on the GUI locally without rebuilding rclone, or for serving a different GUI release than the one embedded. 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 For more help see [the GUI docs](/gui/). `, Annotations: map[string]string{ "versionIntroduced": "v1.74", "groups": "RC", }, RunE: func(command *cobra.Command, args []string) error { cmd.CheckArgs(0, 1, command, args) ctx := context.Background() // Resolve the GUI source (embedded, directory, or .zip) // before binding any sockets so errors surface immediately. var srcPath string if len(args) == 1 { srcPath = args[0] } srcFS, cleanupSrc, err := guiSourceFS(srcPath) if err != nil { return err } defer func() { _ = cleanupSrc() }() // 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 = false if command.Flags().Changed("api-addr") { opt.HTTP.ListenAddr = apiAddr } else { opt.HTTP.ListenAddr = []string{"localhost:0"} } // Forward metrics flag to the RC server. if command.Flags().Changed("enable-metrics") { opt.EnableMetrics = enableMetrics } // 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 { return fmt.Errorf("failed to make password: %w", err) } opt.Auth.BasicPass = randomPass fs.Infof(nil, "No password specified. Using random password: %s", randomPass) } } addr, _ := guiServer.Addr().(*net.TCPAddr) opt.HTTP.AllowOrigin = resolveAllowOrigin(command.Flags().Changed("rc-allow-origin"), opt.HTTP.AllowOrigin, guiOrigin, addr, opt.NoAuth) // Start the RC server (unchanged rcserver.Start) 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, err := guiHandler(srcFS) if err != nil || spaHandler == nil { return fmt.Errorf("failed to start GUI handler: %w", err) } guiServer.Router().Use(middleware.Compress(5)) guiServer.Router().Get("/*", spaHandler.ServeHTTP) guiServer.Router().Head("/*", spaHandler.ServeHTTP) guiServer.Serve() guiURL := guiServer.URLs()[0] guiSource := fmt.Sprintf("version %s", strings.TrimSpace(distTag)) if srcPath != "" { guiSource = fmt.Sprintf("from %s", srcPath) } fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) // Open browser loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) fs.Logf(nil, "GUI available at %s", loginURL) 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 } // resolveAllowOrigin picks the Access-Control-Allow-Origin value for the RC // API server. An explicit --rc-allow-origin always wins. Otherwise a value is // derived from how the GUI is bound: the bound origin (e.g. http://[::]:5522) // is never what a browser sends in its Origin header when the GUI is bound to // a wildcard address, and the GUI may be reached via any number of hosts // (localhost, a LAN IP, a Docker host), so no single origin can match them // all. func resolveAllowOrigin(explicitAllowOrigin bool, currentAllowOrigin, guiOrigin string, addr *net.TCPAddr, noAuth bool) string { if explicitAllowOrigin { return currentAllowOrigin } switch { case addr == nil || !addr.IP.IsUnspecified(): return guiOrigin case !noAuth: return "*" default: fs.Logf(nil, "GUI bound to a wildcard address with --no-auth: browsers can only use the API from %s. Enable auth or bind --addr to a specific host.", guiOrigin) return guiOrigin } } // guiSourceFS opens the GUI bundle at the given path. An empty path // returns the embedded bundle (read from the zip embedded in the binary). // The returned cleanup func must be called on shutdown (no-op for the // embedded bundle and DirFS, Close for an external zip reader). func guiSourceFS(path string) (iofs.FS, func() error, error) { noop := func() error { return nil } if path == "" { zr, err := zip.NewReader(bytes.NewReader(distZip), int64(len(distZip))) if err != nil { return nil, nil, fmt.Errorf("failed to read embedded GUI zip: was `make fetch-gui` run before building?: %w", err) } if _, err := iofs.Stat(zr, "index.html"); err != nil { return nil, nil, fmt.Errorf("embedded GUI has no index.html: was `make fetch-gui` run before building?: %w", err) } return zr, noop, nil } info, err := os.Stat(path) if err != nil { return nil, nil, fmt.Errorf("failed to stat GUI source %q: %w", path, err) } if info.IsDir() { return os.DirFS(path), noop, nil } if info.Mode().IsRegular() && strings.HasSuffix(strings.ToLower(path), ".zip") { zr, err := zip.OpenReader(path) if err != nil { return nil, nil, fmt.Errorf("failed to open GUI zip %q: %w", path, err) } return zr, zr.Close, nil } return nil, nil, fmt.Errorf("GUI source must be a directory or a .zip file: %q", path) } // 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. func guiHandler(srcFS iofs.FS) (http.Handler, error) { if _, err := iofs.Stat(srcFS, "index.html"); err != nil { return nil, fmt.Errorf("GUI bundle has no index.html: %w", err) } 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) }), nil } // 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() }