github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/backend/sia/sia.go (about) 1 // Package sia provides an interface to the Sia storage system. 2 package sia 3 4 import ( 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "net/http" 11 "net/url" 12 "path" 13 "strings" 14 "time" 15 16 "github.com/rclone/rclone/backend/sia/api" 17 "github.com/rclone/rclone/fs" 18 "github.com/rclone/rclone/fs/config" 19 "github.com/rclone/rclone/fs/config/configmap" 20 "github.com/rclone/rclone/fs/config/configstruct" 21 "github.com/rclone/rclone/fs/config/obscure" 22 "github.com/rclone/rclone/fs/fserrors" 23 "github.com/rclone/rclone/fs/fshttp" 24 "github.com/rclone/rclone/fs/hash" 25 "github.com/rclone/rclone/lib/encoder" 26 "github.com/rclone/rclone/lib/pacer" 27 "github.com/rclone/rclone/lib/rest" 28 ) 29 30 const ( 31 minSleep = 10 * time.Millisecond 32 maxSleep = 2 * time.Second 33 decayConstant = 2 // bigger for slower decay, exponential 34 ) 35 36 // Register with Fs 37 func init() { 38 fs.Register(&fs.RegInfo{ 39 Name: "sia", 40 Description: "Sia Decentralized Cloud", 41 NewFs: NewFs, 42 Options: []fs.Option{{ 43 Name: "api_url", 44 Help: `Sia daemon API URL, like http://sia.daemon.host:9980. 45 46 Note that siad must run with --disable-api-security to open API port for other hosts (not recommended). 47 Keep default if Sia daemon runs on localhost.`, 48 Default: "http://127.0.0.1:9980", 49 Sensitive: true, 50 }, { 51 Name: "api_password", 52 Help: `Sia Daemon API Password. 53 54 Can be found in the apipassword file located in HOME/.sia/ or in the daemon directory.`, 55 IsPassword: true, 56 }, { 57 Name: "user_agent", 58 Help: `Siad User Agent 59 60 Sia daemon requires the 'Sia-Agent' user agent by default for security`, 61 Default: "Sia-Agent", 62 Advanced: true, 63 }, { 64 Name: config.ConfigEncoding, 65 Help: config.ConfigEncodingHelp, 66 Advanced: true, 67 Default: encoder.EncodeInvalidUtf8 | 68 encoder.EncodeCtl | 69 encoder.EncodeDel | 70 encoder.EncodeHashPercent | 71 encoder.EncodeQuestion | 72 encoder.EncodeDot | 73 encoder.EncodeSlash, 74 }, 75 }}) 76 } 77 78 // Options defines the configuration for this backend 79 type Options struct { 80 APIURL string `config:"api_url"` 81 APIPassword string `config:"api_password"` 82 UserAgent string `config:"user_agent"` 83 Enc encoder.MultiEncoder `config:"encoding"` 84 } 85 86 // Fs represents a remote siad 87 type Fs struct { 88 name string // name of this remote 89 root string // the path we are working on if any 90 opt Options // parsed config options 91 features *fs.Features // optional features 92 srv *rest.Client // the connection to siad 93 pacer *fs.Pacer // pacer for API calls 94 } 95 96 // Object describes a Sia object 97 type Object struct { 98 fs *Fs 99 remote string 100 modTime time.Time 101 size int64 102 } 103 104 // Return a string version 105 func (o *Object) String() string { 106 if o == nil { 107 return "<nil>" 108 } 109 return o.remote 110 } 111 112 // Remote returns the remote path 113 func (o *Object) Remote() string { 114 return o.remote 115 } 116 117 // ModTime is the last modified time (read-only) 118 func (o *Object) ModTime(ctx context.Context) time.Time { 119 return o.modTime 120 } 121 122 // Size is the file length 123 func (o *Object) Size() int64 { 124 return o.size 125 } 126 127 // Fs returns the parent Fs 128 func (o *Object) Fs() fs.Info { 129 return o.fs 130 } 131 132 // Hash is not supported 133 func (o *Object) Hash(ctx context.Context, ty hash.Type) (string, error) { 134 return "", hash.ErrUnsupported 135 } 136 137 // Storable returns if this object is storable 138 func (o *Object) Storable() bool { 139 return true 140 } 141 142 // SetModTime is not supported 143 func (o *Object) SetModTime(ctx context.Context, t time.Time) error { 144 return fs.ErrorCantSetModTime 145 } 146 147 // Open an object for read 148 func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) { 149 var optionsFixed []fs.OpenOption 150 for _, opt := range options { 151 if optRange, ok := opt.(*fs.RangeOption); ok { 152 // Ignore range option if file is empty 153 if o.Size() == 0 && optRange.Start == 0 && optRange.End > 0 { 154 continue 155 } 156 } 157 optionsFixed = append(optionsFixed, opt) 158 } 159 160 var resp *http.Response 161 opts := rest.Opts{ 162 Method: "GET", 163 Path: path.Join("/renter/stream/", o.fs.root, o.fs.opt.Enc.FromStandardPath(o.remote)), 164 Options: optionsFixed, 165 } 166 err = o.fs.pacer.Call(func() (bool, error) { 167 resp, err = o.fs.srv.Call(ctx, &opts) 168 return o.fs.shouldRetry(resp, err) 169 }) 170 if err != nil { 171 return nil, err 172 } 173 return resp.Body, err 174 } 175 176 // Update the object with the contents of the io.Reader 177 func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) { 178 size := src.Size() 179 var resp *http.Response 180 opts := rest.Opts{ 181 Method: "POST", 182 Path: path.Join("/renter/uploadstream/", o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))), 183 Body: in, 184 ContentLength: &size, 185 Parameters: url.Values{}, 186 } 187 opts.Parameters.Set("force", "true") 188 189 err = o.fs.pacer.Call(func() (bool, error) { 190 resp, err = o.fs.srv.Call(ctx, &opts) 191 return o.fs.shouldRetry(resp, err) 192 }) 193 194 if err == nil { 195 err = o.readMetaData(ctx) 196 } 197 198 return err 199 } 200 201 // Remove an object 202 func (o *Object) Remove(ctx context.Context) (err error) { 203 var resp *http.Response 204 opts := rest.Opts{ 205 Method: "POST", 206 Path: path.Join("/renter/delete/", o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))), 207 } 208 err = o.fs.pacer.Call(func() (bool, error) { 209 resp, err = o.fs.srv.Call(ctx, &opts) 210 return o.fs.shouldRetry(resp, err) 211 }) 212 213 return err 214 } 215 216 // sync the size and other metadata down for the object 217 func (o *Object) readMetaData(ctx context.Context) (err error) { 218 opts := rest.Opts{ 219 Method: "GET", 220 Path: path.Join("/renter/file/", o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))), 221 } 222 223 var result api.FileResponse 224 var resp *http.Response 225 err = o.fs.pacer.Call(func() (bool, error) { 226 resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result) 227 return o.fs.shouldRetry(resp, err) 228 }) 229 230 if err != nil { 231 return err 232 } 233 234 o.size = int64(result.File.Filesize) 235 o.modTime = result.File.ModTime 236 237 return nil 238 } 239 240 // Name of the remote (as passed into NewFs) 241 func (f *Fs) Name() string { 242 return f.name 243 } 244 245 // Root of the remote (as passed into NewFs) 246 func (f *Fs) Root() string { 247 return f.root 248 } 249 250 // String converts this Fs to a string 251 func (f *Fs) String() string { 252 return fmt.Sprintf("Sia %s", f.opt.APIURL) 253 } 254 255 // Precision is unsupported because ModTime is not changeable 256 func (f *Fs) Precision() time.Duration { 257 return fs.ModTimeNotSupported 258 } 259 260 // Hashes are not exposed anywhere 261 func (f *Fs) Hashes() hash.Set { 262 return hash.Set(hash.None) 263 } 264 265 // Features for this fs 266 func (f *Fs) Features() *fs.Features { 267 return f.features 268 } 269 270 // List files and directories in a directory 271 func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { 272 dirPrefix := f.opt.Enc.FromStandardPath(path.Join(f.root, dir)) + "/" 273 274 var result api.DirectoriesResponse 275 var resp *http.Response 276 opts := rest.Opts{ 277 Method: "GET", 278 Path: path.Join("/renter/dir/", dirPrefix) + "/", 279 } 280 281 err = f.pacer.Call(func() (bool, error) { 282 resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) 283 return f.shouldRetry(resp, err) 284 }) 285 286 if err != nil { 287 return nil, err 288 } 289 290 for _, directory := range result.Directories { 291 if directory.SiaPath+"/" == dirPrefix { 292 continue 293 } 294 295 d := fs.NewDir(f.opt.Enc.ToStandardPath(strings.TrimPrefix(directory.SiaPath, f.opt.Enc.FromStandardPath(f.root)+"/")), directory.MostRecentModTime) 296 entries = append(entries, d) 297 } 298 299 for _, file := range result.Files { 300 o := &Object{fs: f, 301 remote: f.opt.Enc.ToStandardPath(strings.TrimPrefix(file.SiaPath, f.opt.Enc.FromStandardPath(f.root)+"/")), 302 modTime: file.ModTime, 303 size: int64(file.Filesize)} 304 entries = append(entries, o) 305 } 306 307 return entries, nil 308 } 309 310 // NewObject finds the Object at remote. If it can't be found 311 // it returns the error fs.ErrorObjectNotFound. 312 func (f *Fs) NewObject(ctx context.Context, remote string) (o fs.Object, err error) { 313 obj := &Object{ 314 fs: f, 315 remote: remote, 316 } 317 err = obj.readMetaData(ctx) 318 if err != nil { 319 return nil, err 320 } 321 322 return obj, nil 323 } 324 325 // Put the object into the remote siad via uploadstream 326 func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 327 o := &Object{ 328 fs: f, 329 remote: src.Remote(), 330 modTime: src.ModTime(ctx), 331 size: src.Size(), 332 } 333 334 err := o.Update(ctx, in, src, options...) 335 if err == nil { 336 return o, nil 337 } 338 339 // Cleanup stray files left after failed upload 340 for i := 0; i < 5; i++ { 341 cleanObj, cleanErr := f.NewObject(ctx, src.Remote()) 342 if cleanErr == nil { 343 cleanErr = cleanObj.Remove(ctx) 344 } 345 if cleanErr == nil { 346 break 347 } 348 if cleanErr != fs.ErrorObjectNotFound { 349 fs.Logf(f, "%q: cleanup failed upload: %v", src.Remote(), cleanErr) 350 break 351 } 352 time.Sleep(100 * time.Millisecond) 353 } 354 return nil, err 355 } 356 357 // PutStream the object into the remote siad via uploadstream 358 func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 359 return f.Put(ctx, in, src, options...) 360 } 361 362 // Mkdir creates a directory 363 func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) { 364 var resp *http.Response 365 opts := rest.Opts{ 366 Method: "POST", 367 Path: path.Join("/renter/dir/", f.opt.Enc.FromStandardPath(path.Join(f.root, dir))), 368 Parameters: url.Values{}, 369 } 370 opts.Parameters.Set("action", "create") 371 372 err = f.pacer.Call(func() (bool, error) { 373 resp, err = f.srv.Call(ctx, &opts) 374 return f.shouldRetry(resp, err) 375 }) 376 377 if err == fs.ErrorDirExists { 378 err = nil 379 } 380 381 return err 382 } 383 384 // Rmdir removes a directory 385 func (f *Fs) Rmdir(ctx context.Context, dir string) (err error) { 386 var resp *http.Response 387 opts := rest.Opts{ 388 Method: "GET", 389 Path: path.Join("/renter/dir/", f.opt.Enc.FromStandardPath(path.Join(f.root, dir))), 390 } 391 392 var result api.DirectoriesResponse 393 err = f.pacer.Call(func() (bool, error) { 394 resp, err = f.srv.CallJSON(ctx, &opts, nil, &result) 395 return f.shouldRetry(resp, err) 396 }) 397 398 if len(result.Directories) == 0 { 399 return fs.ErrorDirNotFound 400 } else if len(result.Files) > 0 || len(result.Directories) > 1 { 401 return fs.ErrorDirectoryNotEmpty 402 } 403 404 opts = rest.Opts{ 405 Method: "POST", 406 Path: path.Join("/renter/dir/", f.opt.Enc.FromStandardPath(path.Join(f.root, dir))), 407 Parameters: url.Values{}, 408 } 409 opts.Parameters.Set("action", "delete") 410 411 err = f.pacer.Call(func() (bool, error) { 412 resp, err = f.srv.Call(ctx, &opts) 413 return f.shouldRetry(resp, err) 414 }) 415 416 return err 417 } 418 419 // NewFs constructs an Fs from the path 420 func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { 421 // Parse config into Options struct 422 opt := new(Options) 423 err := configstruct.Set(m, opt) 424 if err != nil { 425 return nil, err 426 } 427 428 opt.APIURL = strings.TrimSuffix(opt.APIURL, "/") 429 430 // Parse the endpoint 431 u, err := url.Parse(opt.APIURL) 432 if err != nil { 433 return nil, err 434 } 435 436 rootIsDir := strings.HasSuffix(root, "/") 437 root = strings.Trim(root, "/") 438 439 f := &Fs{ 440 name: name, 441 opt: *opt, 442 root: root, 443 } 444 f.pacer = fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))) 445 446 f.features = (&fs.Features{ 447 CanHaveEmptyDirectories: true, 448 }).Fill(ctx, f) 449 450 // Adjust client config and pass it attached to context 451 cliCtx, cliCfg := fs.AddConfig(ctx) 452 if opt.UserAgent != "" { 453 cliCfg.UserAgent = opt.UserAgent 454 } 455 f.srv = rest.NewClient(fshttp.NewClient(cliCtx)) 456 f.srv.SetRoot(u.String()) 457 f.srv.SetErrorHandler(errorHandler) 458 459 if opt.APIPassword != "" { 460 opt.APIPassword, err = obscure.Reveal(opt.APIPassword) 461 if err != nil { 462 return nil, fmt.Errorf("couldn't decrypt API password: %w", err) 463 } 464 f.srv.SetUserPass("", opt.APIPassword) 465 } 466 467 if root != "" && !rootIsDir { 468 // Check to see if the root actually an existing file 469 remote := path.Base(root) 470 f.root = path.Dir(root) 471 if f.root == "." { 472 f.root = "" 473 } 474 _, err := f.NewObject(ctx, remote) 475 if err != nil { 476 if errors.Is(err, fs.ErrorObjectNotFound) || errors.Is(err, fs.ErrorNotAFile) { 477 // File doesn't exist so return old f 478 f.root = root 479 return f, nil 480 } 481 return nil, err 482 } 483 // return an error with an fs which points to the parent 484 return f, fs.ErrorIsFile 485 } 486 487 return f, nil 488 } 489 490 // errorHandler translates Siad errors into native rclone filesystem errors. 491 // Sadly this is using string matching since Siad can't expose meaningful codes. 492 func errorHandler(resp *http.Response) error { 493 body, err := rest.ReadBody(resp) 494 if err != nil { 495 return fmt.Errorf("error when trying to read error body: %w", err) 496 } 497 // Decode error response 498 errResponse := new(api.Error) 499 err = json.Unmarshal(body, &errResponse) 500 if err != nil { 501 // Set the Message to be the body if we can't parse the JSON 502 errResponse.Message = strings.TrimSpace(string(body)) 503 } 504 errResponse.Status = resp.Status 505 errResponse.StatusCode = resp.StatusCode 506 507 msg := strings.Trim(errResponse.Message, "[]") 508 code := errResponse.StatusCode 509 switch { 510 case code == 400 && msg == "no file known with that path": 511 return fs.ErrorObjectNotFound 512 case code == 400 && strings.HasPrefix(msg, "unable to get the fileinfo from the filesystem") && strings.HasSuffix(msg, "path does not exist"): 513 return fs.ErrorObjectNotFound 514 case code == 500 && strings.HasPrefix(msg, "failed to create directory") && strings.HasSuffix(msg, "a siadir already exists at that location"): 515 return fs.ErrorDirExists 516 case code == 500 && strings.HasPrefix(msg, "failed to get directory contents") && strings.HasSuffix(msg, "path does not exist"): 517 return fs.ErrorDirNotFound 518 case code == 500 && strings.HasSuffix(msg, "no such file or directory"): 519 return fs.ErrorDirNotFound 520 } 521 return errResponse 522 } 523 524 // shouldRetry returns a boolean as to whether this resp and err 525 // deserve to be retried. It returns the err as a convenience 526 func (f *Fs) shouldRetry(resp *http.Response, err error) (bool, error) { 527 return fserrors.ShouldRetry(err), err 528 }