init: 初始化 rclone-webgui 项目结构
外层仓库管理 webgui 源码、Docker 编排与项目文档;rclone 作为
git submodule 锁定在上游 master HEAD(59c86b01b),不携带任何
我们的改动。
- webgui/: webgui 源码(原本位于 rclone/cmd/webgui/)
- web/: 原生 HTML/CSS/JS 静态前端(Anthropic 设计语言)
- webgui.go: Go 子命令源码,仅当自行构建 rclone 二进制时需要
- rclone-cmd-all-add-webgui-import.patch: 把 webgui 注册进
rclone 的 cmd/all/all.go 的补丁,留作 fork 时使用
- rclone/: submodule → github.com/rclone/rclone,纯净不改动
- Dockerfile.webgui: 基于 nginx:1.27-alpine,从 ./webgui/web/
COPY 静态资源
- docker/nginx.conf: SPA 静态托管 + 反向代理 RC API
(/config/、/operations/、/sync/、/job/ 等) 与文件下载
(/<remote>:<path>) 到 rclone rcd 容器,前端同源访问无 CORS
- docker-compose.yml: rclone (官方镜像 + rcd --rc-no-auth
--rc-serve) + gui (nginx) 双服务编排,config 走 bind mount
持久化
- DESIGN.md / CLAUDE.md / README.md: 文档
- .gitignore / .dockerignore: 排除 rclone.conf 等敏感文件,
Docker 构建上下文只剩 webgui/web/ + nginx 配置(几十 KB)
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// 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: %s", randomPass)
|
||||
}
|
||||
}
|
||||
|
||||
// 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; embed user/pass only when auth is on.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user