package main import ( "context" "errors" "fmt" "io" "io/fs" "log" "mime" "net/http" "os" "path" "strings" "time" "golang.org/x/net/webdav" "github.com/go-ldap/ldap/v3" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) type bagageCtxKey string const garageEntry = bagageCtxKey("garage") type garageCtx struct { MC *minio.Client StatCache map[string]*GarageStat } func EnvOrDefault(key, def string) string { if val, ok := os.LookupEnv(key); ok { return val } return def } func main() { log.Println("=== Starting Bagage ===") HttpListen := EnvOrDefault("BAGAGE_HTTP_LISTEN", ":8080") pathPrefix := EnvOrDefault("BAGAGE_WEBDAV_PREFIX", "/webdav") LdapServer := EnvOrDefault("BAGAGE_LDAP_ENDPOINT", "127.0.0.1:1389") UserBaseDN := EnvOrDefault("BAGAGE_LDAP_USER_BASE_DN", "ou=users,dc=deuxfleurs,dc=fr") UserNameAttr := EnvOrDefault("BAGAGE_LDAP_USERNAME_ATTR", "cn") Endpoint := EnvOrDefault("BAGAGE_S3_ENDPOINT", "garage.deuxfleurs.fr") UseSSL := EnvOrDefault("BAGAGE_S3_SSL", "true") == "true" srv := &webdav.Handler{ Prefix: pathPrefix, FileSystem: NewGarageFS(), LockSystem: webdav.NewMemLS(), Logger: func(r *http.Request, err error) { log.Printf("INFO: %s %s %s\n", r.RemoteAddr, r.Method, r.URL) if err != nil { log.Printf("ERR: %v", err) } }, } http.HandleFunc(pathPrefix+"/", func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok { NotAuthorized(w, r) return } ldapSock, err := ldap.Dial("tcp", LdapServer) if err != nil { log.Println(err) InternalError(w, r) return } defer ldapSock.Close() // Check credential userDn := fmt.Sprintf("%s=%s,%s", UserNameAttr, username, UserBaseDN) err = ldapSock.Bind(userDn, password) if err != nil { log.Println(err) NotAuthorized(w, r) return } // Get S3 creds garage_s3_access_key garage_s3_secret_key searchRequest := ldap.NewSearchRequest( userDn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=*)", []string{"garage_s3_access_key", "garage_s3_secret_key"}, nil) sr, err := ldapSock.Search(searchRequest) if err != nil { log.Println(err) InternalError(w, r) return } if len(sr.Entries) != 1 { log.Println("Wrong number of LDAP entries, expected 1, got", len(sr.Entries)) InternalError(w, r) return } access_key := sr.Entries[0].GetAttributeValue("garage_s3_access_key") secret_key := sr.Entries[0].GetAttributeValue("garage_s3_secret_key") if access_key == "" || secret_key == "" { log.Println("Either access key or secret key is missing in LDAP for ", userDn) InternalError(w, r) return } mc, err := minio.New(Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(access_key, secret_key, ""), Secure: UseSSL, }) if err != nil { log.Println(err) InternalError(w, r) return } nctx := context.WithValue(r.Context(), garageEntry, garageCtx{MC: mc, StatCache: make(map[string]*GarageStat)}) srv.ServeHTTP(w, r.WithContext(nctx)) return }) if err := http.ListenAndServe(HttpListen, nil); err != nil { log.Fatalf("Error with WebDAV server: %v", err) } } func NotAuthorized(w http.ResponseWriter, r *http.Request) { w.Header().Set("WWW-Authenticate", `Basic realm="Pour accéder à Bagage, veuillez entrer vos identifiants Deuxfleurs"`) w.WriteHeader(401) w.Write([]byte("401 Unauthorized\n")) } func InternalError(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) w.Write([]byte("500 Internal Server Error\n")) } /* /////// Select Action If no slash or one trailing slash return ListBuckets Else obj := ListObjects If obj.Length == 1 return GetObject Else return obj */ type GarageFS struct{} func NewGarageFS() *GarageFS { grg := new(GarageFS) return grg } func (s *GarageFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error { return errors.New("Not implemented Mkdir") } func (s *GarageFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { log.Println("Stat from GarageFS.OpenFile()", name) NewGarageStatFromFile(ctx, name) return NewGarageFile(ctx, name) } func (s *GarageFS) RemoveAll(ctx context.Context, name string) error { return errors.New("Not implemented RemoveAll") } func (s *GarageFS) Rename(ctx context.Context, oldName, newName string) error { return errors.New("Not implemented Rename") } func (s *GarageFS) Stat(ctx context.Context, name string) (os.FileInfo, error) { log.Println("Stat from GarageFS.Stat()", name) return NewGarageStat(ctx, name) } type GarageFile struct { ctx context.Context mc *minio.Client obj *minio.Object objw *io.PipeWriter donew chan error pos int64 path S3Path } func NewGarageFile(ctx context.Context, path string) (webdav.File, error) { gf := new(GarageFile) gf.ctx = ctx gf.pos = 0 gf.mc = ctx.Value(garageEntry).(garageCtx).MC gf.path = NewS3Path(path) return gf, nil } func (gf *GarageFile) Close() error { err := make([]error, 0) if gf.obj != nil { err = append(err, gf.obj.Close()) gf.obj = nil } if gf.objw != nil { // wait that minio completes its transfers in background err = append(err, gf.objw.Close()) err = append(err, <-gf.donew) gf.donew = nil gf.objw = nil } count := 0 for _, e := range err { if e != nil { count++ log.Println(e) } } if count > 0 { return errors.New(fmt.Sprintf("%d errors when closing this WebDAV File. Read previous logs to know more.", count)) } return nil } func (gf *GarageFile) loadObject() error { if gf.obj == nil { obj, err := gf.mc.GetObject(gf.ctx, gf.path.bucket, gf.path.key, minio.GetObjectOptions{}) if err != nil { return err } gf.obj = obj } return nil } func (gf *GarageFile) Read(p []byte) (n int, err error) { //if gf.Stat() & OBJECT == 0 { /* @FIXME Ideally we would check against OBJECT but we need a non OPAQUE_KEY */ // return 0, os.ErrInvalid //} if err := gf.loadObject(); err != nil { return 0, err } return gf.obj.Read(p) } func (gf *GarageFile) Write(p []byte) (n int, err error) { /*if gf.path.class != OBJECT { return 0, os.ErrInvalid }*/ if gf.objw == nil { if gf.pos != 0 { return 0, errors.New("writing with an offset is not implemented") } r, w := io.Pipe() gf.donew = make(chan error, 1) gf.objw = w contentType := mime.TypeByExtension(path.Ext(gf.path.key)) go func() { _, err := gf.mc.PutObject(context.Background(), gf.path.bucket, gf.path.key, r, -1, minio.PutObjectOptions{ContentType: contentType}) gf.donew <- err }() } return gf.objw.Write(p) } func (gf *GarageFile) Seek(offset int64, whence int) (int64, error) { if err := gf.loadObject(); err != nil { return 0, err } pos, err := gf.obj.Seek(offset, whence) gf.pos += pos return pos, err } /* ReadDir reads the contents of the directory associated with the file f and returns a slice of DirEntry values in directory order. Subsequent calls on the same file will yield later DirEntry records in the directory. If n > 0, ReadDir returns at most n DirEntry records. In this case, if ReadDir returns an empty slice, it will return an error explaining why. At the end of a directory, the error is io.EOF. If n <= 0, ReadDir returns all the DirEntry records remaining in the directory. When it succeeds, it returns a nil error (not io.EOF). */ func (gf *GarageFile) Readdir(count int) ([]fs.FileInfo, error) { if count > 0 { return nil, errors.New("returning a limited number of directory entry is not supported in readdir") } if gf.path.class == ROOT { return gf.readDirRoot(count) } else { return gf.readDirChild(count) } } func (gf *GarageFile) readDirRoot(count int) ([]fs.FileInfo, error) { buckets, err := gf.mc.ListBuckets(gf.ctx) if err != nil { return nil, err } entries := make([]fs.FileInfo, 0, len(buckets)) for _, bucket := range buckets { log.Println("Stat from GarageFile.readDirRoot()", "/"+bucket.Name) ngf, err := NewGarageStat(gf.ctx, "/"+bucket.Name) if err != nil { return nil, err } entries = append(entries, ngf) } return entries, nil } func (gf *GarageFile) readDirChild(count int) ([]fs.FileInfo, error) { prefix := gf.path.key if len(prefix) > 0 && prefix[len(prefix)-1:] != "/" { prefix = prefix + "/" } objs_info := gf.mc.ListObjects(gf.ctx, gf.path.bucket, minio.ListObjectsOptions{ Prefix: prefix, Recursive: false, }) entries := make([]fs.FileInfo, 0) for object := range objs_info { if object.Err != nil { return nil, object.Err } log.Println("Stat from GarageFile.readDirChild()", path.Join("/", gf.path.bucket, object.Key)) ngf, err := NewGarageStatFromObjectInfo(gf.ctx, gf.path.bucket, object) if err != nil { return nil, err } entries = append(entries, ngf) } return entries, nil } func (gf *GarageFile) Stat() (fs.FileInfo, error) { log.Println("Stat from GarageFile.Stat()", gf.path.path) return NewGarageStatFromFile(gf.ctx, gf.path.path) } /* Implements */ // StatObject??? type GarageStat struct { obj minio.ObjectInfo ctx context.Context path S3Path } /* * Stat a path */ func NewGarageStatFromFile(ctx context.Context, path string) (*GarageStat, error) { cache := ctx.Value(garageEntry).(garageCtx).StatCache // Maybe this file is already in our cache? if entry, ok := cache[path]; ok { return entry, nil } // Create a placeholder in case we are creating the object gs := new(GarageStat) gs.ctx = ctx gs.path = NewS3Path(path) if gs.path.class == OPAQUE_KEY { gs.path.class = OBJECT // known because called from GarageFile } gs.obj.Key = gs.path.key gs.obj.LastModified = time.Now() // Maybe this file exists in garage? err := gs.Refresh() if err != nil && !os.IsNotExist(err) { // There is an error and this is not a 404, report it. return nil, err } cache[path] = gs return gs, nil } /* * Stat a path knowing its ObjectInfo */ func NewGarageStatFromObjectInfo(ctx context.Context, bucket string, obj minio.ObjectInfo) (*GarageStat, error) { gs := new(GarageStat) gs.path = NewTrustedS3Path(bucket, obj) gs.obj = obj cache := ctx.Value(garageEntry).(garageCtx).StatCache cache[gs.path.path] = gs return gs, nil } /* * Stat a path without additional information */ func NewGarageStat(ctx context.Context, path string) (*GarageStat, error) { cache := ctx.Value(garageEntry).(garageCtx).StatCache if entry, ok := cache[path]; ok { return entry, nil } gs := new(GarageStat) gs.ctx = ctx gs.path = NewS3Path(path) if err := gs.Refresh(); err != nil { return nil, err } if gs.path.class&OPAQUE_KEY != 0 { return nil, errors.New("Failed to precisely determine the key type, this a logic error.") } cache[path] = gs cache[gs.path.path] = gs return gs, nil } func (gs *GarageStat) Refresh() error { if gs.path.class == ROOT || gs.path.class == BUCKET { return nil } mc := gs.ctx.Value(garageEntry).(garageCtx).MC // Compute the prefix to have the desired behaviour for our stat logic prefix := gs.path.key if prefix[len(prefix)-1:] == "/" { prefix = prefix[:len(prefix)-1] } // Get info and check if the key exists objs_info := mc.ListObjects(gs.ctx, gs.path.bucket, minio.ListObjectsOptions{ Prefix: prefix, Recursive: false, }) found := false for object := range objs_info { if object.Err != nil { return object.Err } if object.Key == prefix || object.Key == prefix+"/" { gs.obj = object gs.path = NewTrustedS3Path(gs.path.bucket, object) found = true break } } if !found { return fs.ErrNotExist } return nil } func (gs *GarageStat) Name() string { if gs.path.class == ROOT { return "/" } else if gs.path.class == BUCKET { return gs.path.bucket } else { return path.Base(gs.path.key) } } func (gs *GarageStat) Size() int64 { return gs.obj.Size } func (gs *GarageStat) Mode() fs.FileMode { if gs.path.class == OBJECT { return fs.ModePerm } else { return fs.ModeDir | fs.ModePerm } } func (gs *GarageStat) ModTime() time.Time { return gs.obj.LastModified } func (gs *GarageStat) IsDir() bool { return gs.path.class != OBJECT } func (gs *GarageStat) Sys() interface{} { return nil } type S3Class int const ( ROOT S3Class = 1 << iota BUCKET COMMON_PREFIX OBJECT OPAQUE_KEY KEY = COMMON_PREFIX | OBJECT | OPAQUE_KEY ) type S3Path struct { path string class S3Class bucket string key string } func NewS3Path(path string) S3Path { exploded_path := strings.SplitN(path, "/", 3) // If there is no bucket name (eg. "/") if len(exploded_path) < 2 || exploded_path[1] == "" { return S3Path{path, ROOT, "", ""} } // If there is no key if len(exploded_path) < 3 || exploded_path[2] == "" { return S3Path{path, BUCKET, exploded_path[1], ""} } return S3Path{path, OPAQUE_KEY, exploded_path[1], exploded_path[2]} } func NewTrustedS3Path(bucket string, obj minio.ObjectInfo) S3Path { cl := OBJECT if obj.Key[len(obj.Key)-1:] == "/" { cl = COMMON_PREFIX } return S3Path{ path: path.Join("/", bucket, obj.Key), bucket: bucket, key: obj.Key, class: cl, } }