diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 8b64f20..ccef844 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -90,6 +90,9 @@ jobs: if: ${{ matrix.os == 'windows-latest' }} run: tree /F + - name: Boot ftp and sftp + run: docker-compose -f docker-compose-test.yml up -d + - name: Download GQL schema run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql" diff --git a/.gitignore b/.gitignore index 2544bdd..6961396 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,5 @@ dist/ /.graphqlconfig schema.graphql *.log -.direnv \ No newline at end of file +.direnv +/SatisfactoryDedicatedServer \ No newline at end of file diff --git a/cfg/test_defaults.go b/cfg/test_defaults.go index d6ac288..c575700 100644 --- a/cfg/test_defaults.go +++ b/cfg/test_defaults.go @@ -1,6 +1,8 @@ package cfg import ( + "log/slog" + "os" "path/filepath" "runtime" @@ -18,4 +20,8 @@ func SetDefaults() { viper.SetDefault("api-base", "https://api.ficsit.dev") viper.SetDefault("graphql-api", "/v2/query") viper.SetDefault("concurrent-downloads", 5) + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) } diff --git a/cli/disk/ftp.go b/cli/disk/ftp.go index c72524d..b5945fc 100644 --- a/cli/disk/ftp.go +++ b/cli/disk/ftp.go @@ -39,7 +39,7 @@ func newFTP(path string) (Disk, error) { return nil, fmt.Errorf("failed to parse ftp url: %w", err) } - c, err := ftp.Dial(u.Host, ftp.DialWithTimeout(time.Second*5)) + c, err := ftp.Dial(u.Host, ftp.DialWithTimeout(time.Second*5), ftp.DialWithForceListHidden(true)) if err != nil { return nil, fmt.Errorf("failed to dial host %s: %w", u.Host, err) } @@ -63,7 +63,11 @@ func (l *ftpDisk) Exists(path string) error { slog.Debug("checking if file exists", slog.String("path", path), slog.String("schema", "ftp")) _, err := l.client.FileSize(path) - return fmt.Errorf("failed to check if file exists: %w", err) + if err != nil { + return fmt.Errorf("failed to check if file exists: %w", err) + } + + return nil } func (l *ftpDisk) Read(path string) ([]byte, error) { @@ -103,9 +107,17 @@ func (l *ftpDisk) Remove(path string) error { l.stepLock.Lock() defer l.stepLock.Unlock() + slog.Debug("going to root directory", slog.String("schema", "ftp")) + err := l.client.ChangeDir("/") + if err != nil { + return fmt.Errorf("failed to change directory: %w", err) + } + slog.Debug("deleting path", slog.String("path", path), slog.String("schema", "ftp")) if err := l.client.Delete(path); err != nil { - return fmt.Errorf("failed to delete path: %w", err) + if err := l.client.RemoveDirRecur(path); err != nil { + return fmt.Errorf("failed to delete path: %w", err) + } } return nil @@ -128,6 +140,8 @@ func (l *ftpDisk) MkDir(path string) error { return err } + currentDir, _ := l.client.CurrentDir() + foundDir := false for _, entry := range dir { if entry.IsDir() && entry.Name() == s { @@ -137,13 +151,13 @@ func (l *ftpDisk) MkDir(path string) error { } if !foundDir { - slog.Debug("making directory", slog.String("dir", s), slog.String("schema", "ftp")) + slog.Debug("making directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp")) if err := l.client.MakeDir(s); err != nil { return fmt.Errorf("failed to make directory: %w", err) } } - slog.Debug("entering directory", slog.String("dir", s), slog.String("schema", "ftp")) + slog.Debug("entering directory", slog.String("dir", s), slog.String("cwd", currentDir), slog.String("schema", "ftp")) if err := l.client.ChangeDir(s); err != nil { return fmt.Errorf("failed to enter directory: %w", err) } diff --git a/cli/disk/sftp.go b/cli/disk/sftp.go index f3d584c..8bca466 100644 --- a/cli/disk/sftp.go +++ b/cli/disk/sftp.go @@ -1,51 +1,172 @@ package disk import ( + "bytes" + "fmt" "io" + "log/slog" + "net/url" + "os" + "strings" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" ) var _ Disk = (*sftpDisk)(nil) type sftpDisk struct { - path string + client *sftp.Client + path string +} + +type sftpEntry struct { + os.FileInfo +} + +func (f sftpEntry) IsDir() bool { + return f.FileInfo.IsDir() +} + +func (f sftpEntry) Name() string { + return f.FileInfo.Name() } func newSFTP(path string) (Disk, error) { - return sftpDisk{path: path}, nil + u, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("failed to parse sftp url: %w", err) + } + + password, ok := u.User.Password() + var auth []ssh.AuthMethod + if ok { + auth = append(auth, ssh.Password(password)) + } + + conn, err := ssh.Dial("tcp", u.Host, &ssh.ClientConfig{ + User: u.User.Username(), + Auth: auth, + + // TODO Somehow use systems hosts file + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to ssh server: %w", err) + } + + client, err := sftp.NewClient(conn) + if err != nil { + return nil, fmt.Errorf("failed to create sftp client: %w", err) + } + + return sftpDisk{ + path: path, + client: client, + }, nil } func (l sftpDisk) Exists(path string) error { //nolint - panic("implement me") + slog.Debug("checking if file exists", slog.String("path", path), slog.String("schema", "sftp")) + + _, err := l.client.Stat(path) + if err != nil { + return fmt.Errorf("failed to check if file exists: %w", err) + } + + return nil } -func (l sftpDisk) Read(path string) ([]byte, error) { //nolint - panic("implement me") +func (l sftpDisk) Read(path string) ([]byte, error) { + slog.Debug("reading file", slog.String("path", path), slog.String("schema", "sftp")) + + f, err := l.client.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to retrieve path: %w", err) + } + + defer f.Close() + + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return data, nil } -func (l sftpDisk) Write(path string, data []byte) error { //nolint - panic("implement me") +func (l sftpDisk) Write(path string, data []byte) error { + slog.Debug("writing to file", slog.String("path", path), slog.String("schema", "sftp")) + + file, err := l.client.Create(path) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + defer file.Close() + + if _, err = io.Copy(file, bytes.NewReader(data)); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil } -func (l sftpDisk) Remove(path string) error { //nolint - panic("implement me") +func (l sftpDisk) Remove(path string) error { + slog.Debug("deleting path", slog.String("path", path), slog.String("schema", "sftp")) + if err := l.client.Remove(path); err != nil { + if err := l.client.RemoveAll(path); err != nil { + return fmt.Errorf("failed to delete path: %w", err) + } + } + + return nil } -func (l sftpDisk) MkDir(path string) error { //nolint - panic("implement me") +func (l sftpDisk) MkDir(path string) error { + slog.Debug("making directory", slog.String("path", path), slog.String("schema", "sftp")) + + if err := l.client.MkdirAll(path); err != nil { + return fmt.Errorf("failed to make directory: %w", err) + } + + return nil } -func (l sftpDisk) ReadDir(path string) ([]Entry, error) { //nolint - panic("implement me") +func (l sftpDisk) ReadDir(path string) ([]Entry, error) { + dir, err := l.client.ReadDir(path) + + slog.Debug("reading directory", slog.String("path", path), slog.String("schema", "sftp")) + + if err != nil { + return nil, fmt.Errorf("failed to list files in directory: %w", err) + } + + entries := make([]Entry, len(dir)) + for i, entry := range dir { + entries[i] = sftpEntry{ + FileInfo: entry, + } + } + + return entries, nil } -func (l sftpDisk) IsNotExist(err error) bool { //nolint - panic("implement me") +func (l sftpDisk) IsNotExist(err error) bool { + return err != nil && strings.Contains(err.Error(), "file does not exist") } -func (l sftpDisk) IsExist(err error) bool { //nolint - panic("implement me") +func (l sftpDisk) IsExist(_ error) bool { + return false } -func (l sftpDisk) Open(path string, flag int) (io.WriteCloser, error) { //nolint - panic("implement me") +func (l sftpDisk) Open(path string, _ int) (io.WriteCloser, error) { + slog.Debug("opening for writing", slog.String("path", path), slog.String("schema", "sftp")) + + f, err := l.client.Create(path) + if err != nil { + slog.Error("failed to open file", slog.Any("err", err)) + } + + return f, nil } diff --git a/cli/installations.go b/cli/installations.go index f3d5e81..738254f 100644 --- a/cli/installations.go +++ b/cli/installations.go @@ -490,6 +490,8 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan<- InstallUpdate) return fmt.Errorf("failed to install mods: %w", err) } + slog.Info("installation completed", slog.String("path", i.Path)) + return nil } diff --git a/cli/installations_test.go b/cli/installations_test.go index bc6720e..715b566 100644 --- a/cli/installations_test.go +++ b/cli/installations_test.go @@ -19,7 +19,7 @@ func TestInstallationsInit(t *testing.T) { testza.AssertNotNil(t, installations) } -func TestAddInstallation(t *testing.T) { +func TestAddLocalInstallation(t *testing.T) { ctx, err := InitCLI(false) testza.AssertNoError(t, err) @@ -50,4 +50,79 @@ func TestAddInstallation(t *testing.T) { err = installation.Install(ctx, installWatcher()) testza.AssertNoError(t, err) } + + err = ctx.Wipe() + testza.AssertNoError(t, err) +} + +func TestAddFTPInstallation(t *testing.T) { + ctx, err := InitCLI(false) + testza.AssertNoError(t, err) + + err = ctx.Wipe() + testza.AssertNoError(t, err) + + err = ctx.ReInit() + testza.AssertNoError(t, err) + + ctx.Provider = MockProvider{} + + profileName := "InstallationTest" + profile, err := ctx.Profiles.AddProfile(profileName) + testza.AssertNoError(t, err) + testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) + testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) + + serverLocation := os.Getenv("SF_DEDICATED_SERVER") + if serverLocation != "" { + installation, err := ctx.Installations.AddInstallation(ctx, "ftp://user:pass@localhost:2121/server", profileName) + testza.AssertNoError(t, err) + testza.AssertNotNil(t, installation) + + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + + installation.Vanilla = true + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + } + + err = ctx.Wipe() + testza.AssertNoError(t, err) +} + +func TestAddSFTPInstallation(t *testing.T) { + ctx, err := InitCLI(false) + testza.AssertNoError(t, err) + + err = ctx.Wipe() + testza.AssertNoError(t, err) + + err = ctx.ReInit() + testza.AssertNoError(t, err) + + ctx.Provider = MockProvider{} + + profileName := "InstallationTest" + profile, err := ctx.Profiles.AddProfile(profileName) + testza.AssertNoError(t, err) + testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) + testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) + + serverLocation := os.Getenv("SF_DEDICATED_SERVER") + if serverLocation != "" { + installation, err := ctx.Installations.AddInstallation(ctx, "sftp://user:pass@localhost:2222/home/user/server", profileName) + testza.AssertNoError(t, err) + testza.AssertNotNil(t, installation) + + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + + installation.Vanilla = true + err = installation.Install(ctx, installWatcher()) + testza.AssertNoError(t, err) + } + + err = ctx.Wipe() + testza.AssertNoError(t, err) } diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100755 index 0000000..b65227b --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,31 @@ +version: '2' + +services: + ftp: + image: fauria/vsftpd:latest + ports: + - "2020:20" + - "2121:21" + - "21100-21110:21100-21110" + volumes: + - ./SatisfactoryDedicatedServer:/home/vsftpd/user/server + environment: + - FTP_USER=user + - FTP_PASS=pass + - PASV_ADDRESS=127.0.0.1 + - PASV_MIN_PORT=21100 + - PASV_MAX_PORT=21110 + - LOG_STDOUT=true + + ssh: + image: lscr.io/linuxserver/openssh-server:latest + ports: + - "2222:2222" + volumes: + - ./SatisfactoryDedicatedServer:/home/user/server + environment: + - PUID=1000 + - PGID=1000 + - PASSWORD_ACCESS=true + - USER_PASSWORD=pass + - USER_NAME=user \ No newline at end of file diff --git a/go.mod b/go.mod index 2b7a958..a36db12 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/jlaffaye/ftp v0.2.0 github.com/lmittmann/tint v1.0.3 github.com/muesli/reflow v0.3.0 + github.com/pkg/sftp v1.13.6 github.com/pterm/pterm v0.12.71 github.com/puzpuzpuz/xsync/v3 v3.0.2 github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f @@ -24,6 +25,7 @@ require ( github.com/satisfactorymodding/ficsit-resolver v0.0.2 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.1 + golang.org/x/crypto v0.16.0 golang.org/x/sync v0.5.0 ) @@ -54,6 +56,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect diff --git a/go.sum b/go.sum index 2521c59..a93a90d 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -155,6 +157,8 @@ github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -239,7 +243,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -251,6 +258,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= @@ -287,6 +295,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= @@ -297,6 +306,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=