1// Package rcserver implements the HTTP endpoint to serve the remote control 2package rcserver 3 4import ( 5 "context" 6 "encoding/base64" 7 "encoding/json" 8 "flag" 9 "fmt" 10 "log" 11 "mime" 12 "net/http" 13 "net/url" 14 "path/filepath" 15 "regexp" 16 "sort" 17 "strings" 18 "sync" 19 "time" 20 21 "github.com/rclone/rclone/fs/rc/webgui" 22 23 "github.com/pkg/errors" 24 "github.com/prometheus/client_golang/prometheus" 25 "github.com/prometheus/client_golang/prometheus/promhttp" 26 "github.com/skratchdot/open-golang/open" 27 28 "github.com/rclone/rclone/cmd/serve/httplib" 29 "github.com/rclone/rclone/fs" 30 "github.com/rclone/rclone/fs/accounting" 31 "github.com/rclone/rclone/fs/cache" 32 "github.com/rclone/rclone/fs/config" 33 "github.com/rclone/rclone/fs/list" 34 "github.com/rclone/rclone/fs/rc" 35 "github.com/rclone/rclone/fs/rc/jobs" 36 "github.com/rclone/rclone/fs/rc/rcflags" 37 "github.com/rclone/rclone/lib/http/serve" 38 "github.com/rclone/rclone/lib/random" 39) 40 41var promHandler http.Handler 42var onlyOnceWarningAllowOrigin sync.Once 43 44func init() { 45 rcloneCollector := accounting.NewRcloneCollector(context.Background()) 46 prometheus.MustRegister(rcloneCollector) 47 promHandler = promhttp.Handler() 48} 49 50// Start the remote control server if configured 51// 52// If the server wasn't configured the *Server returned may be nil 53func Start(ctx context.Context, opt *rc.Options) (*Server, error) { 54 jobs.SetOpt(opt) // set the defaults for jobs 55 if opt.Enabled { 56 // Serve on the DefaultServeMux so can have global registrations appear 57 s := newServer(ctx, opt, http.DefaultServeMux) 58 return s, s.Serve() 59 } 60 return nil, nil 61} 62 63// Server contains everything to run the rc server 64type Server struct { 65 *httplib.Server 66 ctx context.Context // for global config 67 files http.Handler 68 pluginsHandler http.Handler 69 opt *rc.Options 70} 71 72func newServer(ctx context.Context, opt *rc.Options, mux *http.ServeMux) *Server { 73 fileHandler := http.Handler(nil) 74 pluginsHandler := http.Handler(nil) 75 // Add some more mime types which are often missing 76 _ = mime.AddExtensionType(".wasm", "application/wasm") 77 _ = mime.AddExtensionType(".js", "application/javascript") 78 79 cachePath := filepath.Join(config.GetCacheDir(), "webgui") 80 extractPath := filepath.Join(cachePath, "current/build") 81 // File handling 82 if opt.Files != "" { 83 if opt.WebUI { 84 fs.Logf(nil, "--rc-files overrides --rc-web-gui command\n") 85 } 86 fs.Logf(nil, "Serving files from %q", opt.Files) 87 fileHandler = http.FileServer(http.Dir(opt.Files)) 88 } else if opt.WebUI { 89 if err := webgui.CheckAndDownloadWebGUIRelease(opt.WebGUIUpdate, opt.WebGUIForceUpdate, opt.WebGUIFetchURL, config.GetCacheDir()); err != nil { 90 log.Fatalf("Error while fetching the latest release of Web GUI: %v", err) 91 } 92 if opt.NoAuth { 93 opt.NoAuth = false 94 fs.Infof(nil, "Cannot run Web GUI without authentication, using default auth") 95 } 96 if opt.HTTPOptions.BasicUser == "" { 97 opt.HTTPOptions.BasicUser = "gui" 98 fs.Infof(nil, "No username specified. Using default username: %s \n", rcflags.Opt.HTTPOptions.BasicUser) 99 } 100 if opt.HTTPOptions.BasicPass == "" { 101 randomPass, err := random.Password(128) 102 if err != nil { 103 log.Fatalf("Failed to make password: %v", err) 104 } 105 opt.HTTPOptions.BasicPass = randomPass 106 fs.Infof(nil, "No password specified. Using random password: %s \n", randomPass) 107 } 108 opt.Serve = true 109 110 fs.Logf(nil, "Serving Web GUI") 111 fileHandler = http.FileServer(http.Dir(extractPath)) 112 113 pluginsHandler = http.FileServer(http.Dir(webgui.PluginsPath)) 114 } 115 116 s := &Server{ 117 Server: httplib.NewServer(mux, &opt.HTTPOptions), 118 ctx: ctx, 119 opt: opt, 120 files: fileHandler, 121 pluginsHandler: pluginsHandler, 122 } 123 mux.HandleFunc("/", s.handler) 124 125 return s 126} 127 128// Serve runs the http server in the background. 129// 130// Use s.Close() and s.Wait() to shutdown server 131func (s *Server) Serve() error { 132 err := s.Server.Serve() 133 if err != nil { 134 return err 135 } 136 fs.Logf(nil, "Serving remote control on %s", s.URL()) 137 // Open the files in the browser if set 138 if s.files != nil { 139 openURL, err := url.Parse(s.URL()) 140 if err != nil { 141 return errors.Wrap(err, "invalid serving URL") 142 } 143 // Add username, password into the URL if they are set 144 user, pass := s.opt.HTTPOptions.BasicUser, s.opt.HTTPOptions.BasicPass 145 if user != "" && pass != "" { 146 openURL.User = url.UserPassword(user, pass) 147 148 // Base64 encode username and password to be sent through url 149 loginToken := user + ":" + pass 150 parameters := url.Values{} 151 encodedToken := base64.URLEncoding.EncodeToString([]byte(loginToken)) 152 fs.Debugf(nil, "login_token %q", encodedToken) 153 parameters.Add("login_token", encodedToken) 154 openURL.RawQuery = parameters.Encode() 155 openURL.RawPath = "/#/login" 156 } 157 // Don't open browser if serving in testing environment or required not to do so. 158 if flag.Lookup("test.v") == nil && !s.opt.WebGUINoOpenBrowser { 159 if err := open.Start(openURL.String()); err != nil { 160 fs.Errorf(nil, "Failed to open Web GUI in browser: %v. Manually access it at: %s", err, openURL.String()) 161 } 162 } else { 163 fs.Logf(nil, "Web GUI is not automatically opening browser. Navigate to %s to use.", openURL.String()) 164 } 165 } 166 return nil 167} 168 169// writeError writes a formatted error to the output 170func writeError(path string, in rc.Params, w http.ResponseWriter, err error, status int) { 171 fs.Errorf(nil, "rc: %q: error: %v", path, err) 172 params, status := rc.Error(path, in, err, status) 173 w.WriteHeader(status) 174 err = rc.WriteJSON(w, params) 175 if err != nil { 176 // can't return the error at this point 177 fs.Errorf(nil, "rc: writeError: failed to write JSON output from %#v: %v", in, err) 178 } 179} 180 181// handler reads incoming requests and dispatches them 182func (s *Server) handler(w http.ResponseWriter, r *http.Request) { 183 urlPath, ok := s.Path(w, r) 184 if !ok { 185 return 186 } 187 path := strings.TrimLeft(urlPath, "/") 188 189 allowOrigin := rcflags.Opt.AccessControlAllowOrigin 190 if allowOrigin != "" { 191 onlyOnceWarningAllowOrigin.Do(func() { 192 if allowOrigin == "*" { 193 fs.Logf(nil, "Warning: Allow origin set to *. This can cause serious security problems.") 194 } 195 }) 196 w.Header().Add("Access-Control-Allow-Origin", allowOrigin) 197 } else { 198 w.Header().Add("Access-Control-Allow-Origin", s.URL()) 199 } 200 201 // echo back access control headers client needs 202 //reqAccessHeaders := r.Header.Get("Access-Control-Request-Headers") 203 w.Header().Add("Access-Control-Request-Method", "POST, OPTIONS, GET, HEAD") 204 w.Header().Add("Access-Control-Allow-Headers", "authorization, Content-Type") 205 206 switch r.Method { 207 case "POST": 208 s.handlePost(w, r, path) 209 case "OPTIONS": 210 s.handleOptions(w, r, path) 211 case "GET", "HEAD": 212 s.handleGet(w, r, path) 213 default: 214 writeError(path, nil, w, errors.Errorf("method %q not allowed", r.Method), http.StatusMethodNotAllowed) 215 return 216 } 217} 218 219func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string) { 220 ctx := r.Context() 221 contentType := r.Header.Get("Content-Type") 222 223 values := r.URL.Query() 224 if contentType == "application/x-www-form-urlencoded" { 225 // Parse the POST and URL parameters into r.Form, for others r.Form will be empty value 226 err := r.ParseForm() 227 if err != nil { 228 writeError(path, nil, w, errors.Wrap(err, "failed to parse form/URL parameters"), http.StatusBadRequest) 229 return 230 } 231 values = r.Form 232 } 233 234 // Read the POST and URL parameters into in 235 in := make(rc.Params) 236 for k, vs := range values { 237 if len(vs) > 0 { 238 in[k] = vs[len(vs)-1] 239 } 240 } 241 242 // Parse a JSON blob from the input 243 if contentType == "application/json" { 244 err := json.NewDecoder(r.Body).Decode(&in) 245 if err != nil { 246 writeError(path, in, w, errors.Wrap(err, "failed to read input JSON"), http.StatusBadRequest) 247 return 248 } 249 } 250 // Find the call 251 call := rc.Calls.Get(path) 252 if call == nil { 253 writeError(path, in, w, errors.Errorf("couldn't find method %q", path), http.StatusNotFound) 254 return 255 } 256 257 // Check to see if it requires authorisation 258 if !s.opt.NoAuth && call.AuthRequired && !s.UsingAuth() { 259 writeError(path, in, w, errors.Errorf("authentication must be set up on the rc server to use %q or the --rc-no-auth flag must be in use", path), http.StatusForbidden) 260 return 261 } 262 263 inOrig := in.Copy() 264 265 if call.NeedsRequest { 266 // Add the request to RC 267 in["_request"] = r 268 } 269 270 if call.NeedsResponse { 271 in["_response"] = w 272 } 273 274 fs.Debugf(nil, "rc: %q: with parameters %+v", path, in) 275 job, out, err := jobs.NewJob(ctx, call.Fn, in) 276 if job != nil { 277 w.Header().Add("x-rclone-jobid", fmt.Sprintf("%d", job.ID)) 278 } 279 if err != nil { 280 writeError(path, inOrig, w, err, http.StatusInternalServerError) 281 return 282 } 283 if out == nil { 284 out = make(rc.Params) 285 } 286 287 fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err) 288 err = rc.WriteJSON(w, out) 289 if err != nil { 290 // can't return the error at this point - but have a go anyway 291 writeError(path, inOrig, w, err, http.StatusInternalServerError) 292 fs.Errorf(nil, "rc: handlePost: failed to write JSON output: %v", err) 293 } 294} 295 296func (s *Server) handleOptions(w http.ResponseWriter, r *http.Request, path string) { 297 w.WriteHeader(http.StatusOK) 298} 299 300func (s *Server) serveRoot(w http.ResponseWriter, r *http.Request) { 301 remotes := config.FileSections() 302 sort.Strings(remotes) 303 directory := serve.NewDirectory("", s.HTMLTemplate) 304 directory.Name = "List of all rclone remotes." 305 q := url.Values{} 306 for _, remote := range remotes { 307 q.Set("fs", remote) 308 directory.AddHTMLEntry("["+remote+":]", true, -1, time.Time{}) 309 } 310 sortParm := r.URL.Query().Get("sort") 311 orderParm := r.URL.Query().Get("order") 312 directory.ProcessQueryParams(sortParm, orderParm) 313 314 directory.Serve(w, r) 315} 316 317func (s *Server) serveRemote(w http.ResponseWriter, r *http.Request, path string, fsName string) { 318 f, err := cache.Get(s.ctx, fsName) 319 if err != nil { 320 writeError(path, nil, w, errors.Wrap(err, "failed to make Fs"), http.StatusInternalServerError) 321 return 322 } 323 if path == "" || strings.HasSuffix(path, "/") { 324 path = strings.Trim(path, "/") 325 entries, err := list.DirSorted(r.Context(), f, false, path) 326 if err != nil { 327 writeError(path, nil, w, errors.Wrap(err, "failed to list directory"), http.StatusInternalServerError) 328 return 329 } 330 // Make the entries for display 331 directory := serve.NewDirectory(path, s.HTMLTemplate) 332 for _, entry := range entries { 333 _, isDir := entry.(fs.Directory) 334 //directory.AddHTMLEntry(entry.Remote(), isDir, entry.Size(), entry.ModTime(r.Context())) 335 directory.AddHTMLEntry(entry.Remote(), isDir, entry.Size(), time.Time{}) 336 } 337 sortParm := r.URL.Query().Get("sort") 338 orderParm := r.URL.Query().Get("order") 339 directory.ProcessQueryParams(sortParm, orderParm) 340 341 directory.Serve(w, r) 342 } else { 343 path = strings.Trim(path, "/") 344 o, err := f.NewObject(r.Context(), path) 345 if err != nil { 346 writeError(path, nil, w, errors.Wrap(err, "failed to find object"), http.StatusInternalServerError) 347 return 348 } 349 serve.Object(w, r, o) 350 } 351} 352 353// Match URLS of the form [fs]/remote 354var fsMatch = regexp.MustCompile(`^\[(.*?)\](.*)$`) 355 356func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { 357 // Look to see if this has an fs in the path 358 fsMatchResult := fsMatch.FindStringSubmatch(path) 359 360 switch { 361 case fsMatchResult != nil && s.opt.Serve: 362 // Serve /[fs]/remote files 363 s.serveRemote(w, r, fsMatchResult[2], fsMatchResult[1]) 364 return 365 case path == "metrics" && s.opt.EnableMetrics: 366 promHandler.ServeHTTP(w, r) 367 return 368 case path == "*" && s.opt.Serve: 369 // Serve /* as the remote listing 370 s.serveRoot(w, r) 371 return 372 case s.files != nil: 373 if s.opt.WebUI { 374 pluginsMatchResult := webgui.PluginsMatch.FindStringSubmatch(path) 375 376 if pluginsMatchResult != nil && len(pluginsMatchResult) > 2 { 377 ok := webgui.ServePluginOK(w, r, pluginsMatchResult) 378 if !ok { 379 r.URL.Path = fmt.Sprintf("/%s/%s/app/build/%s", pluginsMatchResult[1], pluginsMatchResult[2], pluginsMatchResult[3]) 380 s.pluginsHandler.ServeHTTP(w, r) 381 return 382 } 383 return 384 } else if webgui.ServePluginWithReferrerOK(w, r, path) { 385 return 386 } 387 } 388 // Serve the files 389 r.URL.Path = "/" + path 390 s.files.ServeHTTP(w, r) 391 return 392 case path == "" && s.opt.Serve: 393 // Serve the root as a remote listing 394 s.serveRoot(w, r) 395 return 396 } 397 http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 398} 399