The auth proxy was only given the user and their password or public key, so a proxy program had no way to restrict logins to particular networks, or to record where an authentication attempt came from. The JSON sent to the program now has a client_ip key holding the bare IP the client connected from, with the port stripped so IPv6 arrives as 2001:db8::1 rather than [2001:db8::1]:52344. An IPv4-mapped IPv6 address is reported as plain IPv4 so that a client arriving over a dual-stack listener still matches IPv4 networks. The key is omitted when the client has no IP address. The IP is also mixed into the backend cache key. That is needed as the program is only run on a cache miss, so a client from a non-allowlisted address presenting valid credentials within the 5 minute cache lifetime would get a cache hit and be let in without the program being consulted at all.
659 lines
16 KiB
Go
659 lines
16 KiB
Go
//go:build !plan9
|
|
|
|
// Package ftp implements an FTP server for rclone
|
|
package ftp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
iofs "io/fs"
|
|
"net"
|
|
"os"
|
|
"os/user"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rclone/rclone/cmd"
|
|
"github.com/rclone/rclone/cmd/serve"
|
|
"github.com/rclone/rclone/cmd/serve/proxy"
|
|
"github.com/rclone/rclone/cmd/serve/proxy/proxyflags"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/accounting"
|
|
"github.com/rclone/rclone/fs/config/flags"
|
|
"github.com/rclone/rclone/fs/config/obscure"
|
|
"github.com/rclone/rclone/fs/log"
|
|
"github.com/rclone/rclone/fs/rc"
|
|
"github.com/rclone/rclone/vfs"
|
|
"github.com/rclone/rclone/vfs/vfscommon"
|
|
"github.com/rclone/rclone/vfs/vfsflags"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
ftp "goftp.io/server/v2"
|
|
)
|
|
|
|
// OptionsInfo descripts the Options in use
|
|
var OptionsInfo = fs.Options{{
|
|
Name: "addr",
|
|
Default: "localhost:2121",
|
|
Help: "IPaddress:Port or :Port to bind server to",
|
|
}, {
|
|
Name: "public_ip",
|
|
Default: "",
|
|
Help: "Public IP address to advertise for passive connections",
|
|
}, {
|
|
Name: "passive_port",
|
|
Default: "30000-32000",
|
|
Help: "Passive port range to use",
|
|
}, {
|
|
Name: "user",
|
|
Default: "anonymous",
|
|
Help: "User name for authentication",
|
|
}, {
|
|
Name: "pass",
|
|
Default: "",
|
|
Help: "Password for authentication (empty value allow every password)",
|
|
}, {
|
|
Name: "cert",
|
|
Default: "",
|
|
Help: "TLS PEM key (concatenation of certificate and CA certificate)",
|
|
}, {
|
|
Name: "key",
|
|
Default: "",
|
|
Help: "TLS PEM Private key",
|
|
}}
|
|
|
|
// Options contains options for the http Server
|
|
type Options struct {
|
|
//TODO add more options
|
|
ListenAddr string `config:"addr"` // Port to listen on
|
|
PublicIP string `config:"public_ip"` // Passive ports range
|
|
PassivePorts string `config:"passive_port"` // Passive ports range
|
|
User string `config:"user"` // single username for basic auth if not using Htpasswd
|
|
Pass string `config:"pass"` // password for User
|
|
TLSCert string `config:"cert"` // TLS PEM key (concatenation of certificate and CA certificate)
|
|
TLSKey string `config:"key"` // TLS PEM Private key
|
|
}
|
|
|
|
// Opt is options set by command line flags
|
|
var Opt Options
|
|
|
|
// AddFlags adds flags for ftp
|
|
func AddFlags(flagSet *pflag.FlagSet) {
|
|
flags.AddFlagsFromOptions(flagSet, "", OptionsInfo)
|
|
}
|
|
|
|
func init() {
|
|
vfsflags.AddFlags(Command.Flags())
|
|
proxyflags.AddFlags(Command.Flags())
|
|
AddFlags(Command.Flags())
|
|
serve.Command.AddCommand(Command)
|
|
serve.AddRc("ftp", func(ctx context.Context, f fs.Fs, in rc.Params) (serve.Handle, error) {
|
|
// Read VFS Opts
|
|
var vfsOpt = vfscommon.Opt // set default opts
|
|
err := rc.ParseOptions(in, "vfsOpt", &vfsOpt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Read Proxy Opts
|
|
var proxyOpt = proxy.Opt // set default opts
|
|
err = rc.ParseOptions(in, "proxyOpt", &proxyOpt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Read opts
|
|
var opt = Opt // set default opts
|
|
err = rc.ParseOptions(in, "opt", &opt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Create server
|
|
return newServer(ctx, f, &opt, &vfsOpt, &proxyOpt)
|
|
})
|
|
}
|
|
|
|
// Command definition for cobra
|
|
var Command = &cobra.Command{
|
|
Use: "ftp remote:path",
|
|
Short: `Serve remote:path over FTP.`,
|
|
Long: `Run a basic FTP server to serve a remote over FTP protocol.
|
|
This can be viewed with a FTP client or you can make a remote of
|
|
type FTP to read and write it.
|
|
|
|
### Server options
|
|
|
|
Use --addr to specify which IP address and port the server should
|
|
listen on, e.g. --addr 1.2.3.4:8000 or --addr :8080 to listen to all
|
|
IPs. By default it only listens on localhost. You can use port
|
|
:0 to let the OS choose an available port.
|
|
|
|
If you set --addr to listen on a public or LAN accessible IP address
|
|
then using Authentication is advised - see the next section for info.
|
|
|
|
#### Authentication
|
|
|
|
By default this will serve files without needing a login.
|
|
|
|
You can set a single username and password with the --user and --pass flags.
|
|
|
|
` + strings.TrimSpace(vfs.Help()+proxy.Help),
|
|
Annotations: map[string]string{
|
|
"versionIntroduced": "v1.44",
|
|
"groups": "Filter",
|
|
},
|
|
Run: func(command *cobra.Command, args []string) {
|
|
var f fs.Fs
|
|
if proxy.Opt.AuthProxy == "" {
|
|
cmd.CheckArgs(1, 1, command, args)
|
|
f = cmd.NewFsSrc(args)
|
|
} else {
|
|
cmd.CheckArgs(0, 0, command, args)
|
|
}
|
|
cmd.Run(false, false, command, func() error {
|
|
s, err := newServer(context.Background(), f, &Opt, &vfscommon.Opt, &proxy.Opt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Serve()
|
|
})
|
|
},
|
|
}
|
|
|
|
// driver contains everything to run the driver for the FTP server
|
|
type driver struct {
|
|
f fs.Fs
|
|
srv *ftp.Server
|
|
ctx context.Context // for global config
|
|
opt Options
|
|
globalVFS *vfs.VFS // the VFS if not using auth proxy
|
|
proxy *proxy.Proxy // may be nil if not in use
|
|
useTLS bool
|
|
userPassMu sync.Mutex // to protect userPass
|
|
userPass map[string]string // cache of username => password when using vfs proxy
|
|
}
|
|
|
|
func init() {
|
|
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "ftp", Opt: &Opt, Options: OptionsInfo})
|
|
}
|
|
|
|
var passivePortsRe = regexp.MustCompile(`^\s*\d+\s*-\s*\d+\s*$`)
|
|
|
|
// Make a new FTP to serve the remote
|
|
func newServer(ctx context.Context, f fs.Fs, opt *Options, vfsOpt *vfscommon.Options, proxyOpt *proxy.Options) (*driver, error) {
|
|
host, port, err := net.SplitHostPort(opt.ListenAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse host:port from %q", opt.ListenAddr)
|
|
}
|
|
portNum, err := strconv.Atoi(port)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse port number from %q", port)
|
|
}
|
|
|
|
d := &driver{
|
|
f: f,
|
|
ctx: ctx,
|
|
opt: *opt,
|
|
}
|
|
if proxy.Opt.AuthProxy != "" {
|
|
d.proxy = proxy.New(ctx, proxyOpt, vfsOpt)
|
|
d.userPass = make(map[string]string, 16)
|
|
} else {
|
|
d.globalVFS = vfs.New(ctx, f, vfsOpt)
|
|
}
|
|
d.useTLS = d.opt.TLSKey != ""
|
|
|
|
// Check PassivePorts format since the server library doesn't!
|
|
if !passivePortsRe.MatchString(opt.PassivePorts) {
|
|
return nil, fmt.Errorf("invalid format for passive ports %q", opt.PassivePorts)
|
|
}
|
|
|
|
ftpopt := &ftp.Options{
|
|
Name: "Rclone FTP Server",
|
|
WelcomeMessage: "Welcome to Rclone " + fs.Version + " FTP Server",
|
|
Driver: d,
|
|
Hostname: host,
|
|
Port: portNum,
|
|
PublicIP: opt.PublicIP,
|
|
PassivePorts: opt.PassivePorts,
|
|
Auth: d,
|
|
Perm: ftp.NewSimplePerm("ftp", "ftp"), // fake user and group
|
|
Logger: &Logger{},
|
|
TLS: d.useTLS,
|
|
CertFile: d.opt.TLSCert,
|
|
KeyFile: d.opt.TLSKey,
|
|
//TODO implement a maximum of https://godoc.org/goftp.io/server#ServerOpts
|
|
}
|
|
d.srv, err = ftp.NewServer(ftpopt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create new FTP server: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// Serve runs the FTP server until it is shutdown
|
|
func (d *driver) Serve() error {
|
|
fs.Logf(d.f, "Serving FTP on %s", d.srv.Hostname+":"+strconv.Itoa(d.srv.Port))
|
|
err := d.srv.ListenAndServe()
|
|
if err == ftp.ErrServerClosed {
|
|
err = nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Shutdown stops the ftp server
|
|
//
|
|
//lint:ignore U1000 unused when not building linux
|
|
func (d *driver) Shutdown() error {
|
|
fs.Logf(d.f, "Stopping FTP on %s", d.srv.Hostname+":"+strconv.Itoa(d.srv.Port))
|
|
return d.srv.Shutdown()
|
|
}
|
|
|
|
// Return the first address of the server
|
|
func (d *driver) Addr() net.Addr {
|
|
// The FTP server doesn't let us read the listener
|
|
// so we have to synthesize the net.Addr here.
|
|
// On errors we'll return a zero item or zero parts.
|
|
addr := &net.TCPAddr{}
|
|
|
|
// Split host and port
|
|
host, port, err := net.SplitHostPort(d.opt.ListenAddr)
|
|
if err != nil {
|
|
fs.Errorf(nil, "ftp: addr: invalid address format: %v", err)
|
|
return addr
|
|
}
|
|
|
|
// Parse port
|
|
addr.Port, err = strconv.Atoi(port)
|
|
if err != nil {
|
|
fs.Errorf(nil, "ftp: addr: invalid port number: %v", err)
|
|
}
|
|
|
|
// Resolve the host to an IP address.
|
|
ipAddrs, err := net.LookupIP(host)
|
|
if err != nil {
|
|
fs.Errorf(nil, "ftp: addr: failed to resolve host: %v", err)
|
|
} else if len(ipAddrs) == 0 {
|
|
fs.Errorf(nil, "ftp: addr: no IP addresses found for host: %s", host)
|
|
} else {
|
|
// Choose the first IP address.
|
|
addr.IP = ipAddrs[0]
|
|
}
|
|
|
|
return addr
|
|
}
|
|
|
|
// Logger ftp logger output formatted message
|
|
type Logger struct{}
|
|
|
|
// Print log simple text message
|
|
func (l *Logger) Print(sessionID string, message any) {
|
|
fs.Infof(sessionID, "%s", message)
|
|
}
|
|
|
|
// Printf log formatted text message
|
|
func (l *Logger) Printf(sessionID string, format string, v ...any) {
|
|
fs.Infof(sessionID, format, v...)
|
|
}
|
|
|
|
// PrintCommand log formatted command execution
|
|
func (l *Logger) PrintCommand(sessionID string, command string, params string) {
|
|
if command == "PASS" {
|
|
fs.Infof(sessionID, "> PASS ****")
|
|
} else {
|
|
fs.Infof(sessionID, "> %s %s", command, params)
|
|
}
|
|
}
|
|
|
|
// PrintResponse log responses
|
|
func (l *Logger) PrintResponse(sessionID string, code int, message string) {
|
|
fs.Infof(sessionID, "< %d %s", code, message)
|
|
}
|
|
|
|
// CheckPasswd handle auth based on configuration
|
|
func (d *driver) CheckPasswd(sctx *ftp.Context, user, pass string) (ok bool, err error) {
|
|
if d.proxy != nil {
|
|
_, _, err = d.proxy.Call(user, pass, false, sctx.Sess.RemoteAddr().String())
|
|
if err != nil {
|
|
fs.Infof(nil, "proxy login failed: %v", err)
|
|
return false, nil
|
|
}
|
|
// Cache obscured password for later lookup.
|
|
//
|
|
// We don't cache the VFS directly in the driver as we want them
|
|
// to be expired and the auth proxy does that for us.
|
|
oPass, err := obscure.Obscure(pass)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
d.userPassMu.Lock()
|
|
d.userPass[user] = oPass
|
|
d.userPassMu.Unlock()
|
|
} else {
|
|
userOK := subtle.ConstantTimeCompare([]byte(d.opt.User), []byte(user))
|
|
// No password configured means any password is accepted
|
|
passOK := 1
|
|
if d.opt.Pass != "" {
|
|
passOK = subtle.ConstantTimeCompare([]byte(d.opt.Pass), []byte(pass))
|
|
}
|
|
ok = (userOK & passOK) == 1
|
|
if !ok {
|
|
fs.Infof(nil, "login failed: bad credentials")
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Get the VFS for this connection
|
|
func (d *driver) getVFS(sctx *ftp.Context) (VFS *vfs.VFS, err error) {
|
|
if d.proxy == nil {
|
|
// If no proxy always use the same VFS
|
|
return d.globalVFS, nil
|
|
}
|
|
user := sctx.Sess.LoginUser()
|
|
d.userPassMu.Lock()
|
|
oPass, ok := d.userPass[user]
|
|
d.userPassMu.Unlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("proxy user not logged in")
|
|
}
|
|
pass, err := obscure.Reveal(oPass)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
VFS, _, err = d.proxy.Call(user, pass, false, sctx.Sess.RemoteAddr().String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("proxy login failed: %w", err)
|
|
}
|
|
return VFS, nil
|
|
}
|
|
|
|
// Stat get information on file or folder
|
|
func (d *driver) Stat(sctx *ftp.Context, path string) (fi iofs.FileInfo, err error) {
|
|
defer log.Trace(path, "")("fi=%+v, err = %v", &fi, &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &FileInfo{n, n.Mode(), VFS.Opt.UID, VFS.Opt.GID}, err
|
|
}
|
|
|
|
// ChangeDir move current folder
|
|
func (d *driver) ChangeDir(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !n.IsDir() {
|
|
return errors.New("not a directory")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListDir list content of a folder
|
|
func (d *driver) ListDir(sctx *ftp.Context, path string, callback func(iofs.FileInfo) error) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err == vfs.ENOENT {
|
|
return errors.New("directory not found")
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if !node.IsDir() {
|
|
return errors.New("not a directory")
|
|
}
|
|
|
|
dir := node.(*vfs.Dir)
|
|
dirEntries, err := dir.ReadDirAll()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Account the transfer
|
|
tr := accounting.GlobalStats().NewTransferRemoteSize(path, node.Size(), d.f, nil)
|
|
defer func() {
|
|
tr.Done(d.ctx, err)
|
|
}()
|
|
|
|
for _, file := range dirEntries {
|
|
err = callback(&FileInfo{file, file.Mode(), VFS.Opt.UID, VFS.Opt.GID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteDir delete a folder and his content
|
|
func (d *driver) DeleteDir(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !node.IsDir() {
|
|
return errors.New("not a directory")
|
|
}
|
|
err = node.Remove()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteFile delete a file
|
|
func (d *driver) DeleteFile(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !node.IsFile() {
|
|
return errors.New("not a file")
|
|
}
|
|
err = node.Remove()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Rename rename a file or folder
|
|
func (d *driver) Rename(sctx *ftp.Context, oldName, newName string) (err error) {
|
|
defer log.Trace(oldName, "newName=%q", newName)("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return VFS.Rename(oldName, newName)
|
|
}
|
|
|
|
// MakeDir create a folder
|
|
func (d *driver) MakeDir(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir, leaf, err := VFS.StatParent(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = dir.Mkdir(leaf)
|
|
return err
|
|
}
|
|
|
|
// GetFile download a file
|
|
func (d *driver) GetFile(sctx *ftp.Context, path string, offset int64) (size int64, fr io.ReadCloser, err error) {
|
|
defer log.Trace(path, "offset=%v", offset)("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err == vfs.ENOENT {
|
|
fs.Infof(path, "File not found")
|
|
return 0, nil, errors.New("file not found")
|
|
} else if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if !node.IsFile() {
|
|
return 0, nil, errors.New("not a file")
|
|
}
|
|
|
|
handle, err := node.Open(os.O_RDONLY)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
_, err = handle.Seek(offset, io.SeekStart)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
// Account the transfer
|
|
tr := accounting.GlobalStats().NewTransferRemoteSize(path, node.Size(), d.f, nil)
|
|
defer tr.Done(d.ctx, nil)
|
|
|
|
return node.Size(), handle, nil
|
|
}
|
|
|
|
// PutFile upload a file
|
|
func (d *driver) PutFile(sctx *ftp.Context, path string, data io.Reader, offset int64) (n int64, err error) {
|
|
defer log.Trace(path, "offset=%d", offset)("err = %v", &err)
|
|
|
|
var isExist bool
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
fi, err := VFS.Stat(path)
|
|
if err == nil {
|
|
isExist = true
|
|
if fi.IsDir() {
|
|
return 0, errors.New("can't create file - directory exists")
|
|
}
|
|
} else {
|
|
if os.IsNotExist(err) {
|
|
isExist = false
|
|
} else {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
if offset > -1 && !isExist {
|
|
offset = -1
|
|
}
|
|
|
|
var f vfs.Handle
|
|
|
|
if offset == -1 {
|
|
if isExist {
|
|
err = VFS.Remove(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
f, err = VFS.Create(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer fs.CheckClose(f, &err)
|
|
n, err = io.Copy(f, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
f, err = VFS.OpenFile(path, os.O_APPEND|os.O_RDWR, 0660)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer fs.CheckClose(f, &err)
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if offset > info.Size() {
|
|
return 0, fmt.Errorf("offset %d is beyond file size %d", offset, info.Size())
|
|
}
|
|
|
|
_, err = f.Seek(offset, io.SeekStart)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
bytes, err := io.Copy(f, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return bytes, nil
|
|
}
|
|
|
|
// FileInfo struct to hold file info for ftp server
|
|
type FileInfo struct {
|
|
os.FileInfo
|
|
|
|
mode os.FileMode
|
|
owner uint32
|
|
group uint32
|
|
}
|
|
|
|
// Mode return mode of file.
|
|
func (f *FileInfo) Mode() os.FileMode {
|
|
return f.mode
|
|
}
|
|
|
|
// Owner return owner of file. Try to find the username if possible
|
|
func (f *FileInfo) Owner() string {
|
|
str := fmt.Sprint(f.owner)
|
|
u, err := user.LookupId(str)
|
|
if err != nil {
|
|
return str //User not found
|
|
}
|
|
return u.Username
|
|
}
|
|
|
|
// Group return group of file. Try to find the group name if possible
|
|
func (f *FileInfo) Group() string {
|
|
str := fmt.Sprint(f.group)
|
|
g, err := user.LookupGroupId(str)
|
|
if err != nil {
|
|
return str //Group not found default to numerical value
|
|
}
|
|
return g.Name
|
|
}
|
|
|
|
// ModTime returns the time in UTC
|
|
func (f *FileInfo) ModTime() time.Time {
|
|
return f.FileInfo.ModTime().UTC()
|
|
}
|