diff --git a/go.mod b/go.mod index 58d77e9..482fbdf 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/rwinkhart/MUTN go 1.22.2 require ( + github.com/bramvdbogaerde/go-scp v1.4.0 github.com/charmbracelet/glamour v0.7.0 github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727 github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481 diff --git a/go.sum b/go.sum index 8bfd005..010d995 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3 github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bramvdbogaerde/go-scp v1.4.0 h1:jKMwpwCbcX1KyvDbm/PDJuXcMuNVlLGi0Q0reuzjyKY= +github.com/bramvdbogaerde/go-scp v1.4.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ= github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/src/sync/2globalsUNIX.go b/src/sync/2globalsUNIX.go new file mode 100644 index 0000000..54758b1 --- /dev/null +++ b/src/sync/2globalsUNIX.go @@ -0,0 +1,5 @@ +//go:build !windows + +package sync + +const bareEntryRoot = "/.local/share/libmutton" diff --git a/src/sync/2globalsWIN.go b/src/sync/2globalsWIN.go new file mode 100644 index 0000000..011c7bf --- /dev/null +++ b/src/sync/2globalsWIN.go @@ -0,0 +1,5 @@ +//go:build windows + +package sync + +const bareEntryRoot = "\\AppData\\Local\\libmutton\\entries" diff --git a/src/sync/client.go b/src/sync/client.go index 3596b3e..6da3ab3 100644 --- a/src/sync/client.go +++ b/src/sync/client.go @@ -1,7 +1,9 @@ package sync import ( + "context" "fmt" + "github.com/bramvdbogaerde/go-scp" "github.com/rwinkhart/MUTN/src/backend" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" @@ -10,6 +12,11 @@ import ( "strings" ) +// TODO it is likely that all of this breaks on Windows, since the server returns unpredictable paths +// See what libmuttonserver fetch returns on Windows +// If the path separators are different, then we'll have to save the server OS during init and adjust accordingly +// I would prefer not to unnecessarily replace the path separators if syncing from Unix->Unix or Windows->Windows + // global constants used only in this file const ( ansiDelete = "\033[38;5;1m" @@ -17,9 +24,9 @@ const ( ansiUpload = "\033[38;5;4m" ) -// GetSSHOutput runs a command over SSH and returns the output +// getSSHClient returns an SSH client connection to the server (also returns the remote username as a string) // only supports key-based authentication (passphrases are supported for CLI-based implementations) -func GetSSHOutput(cmd string, manualSync bool) string { +func getSSHClient(manualSync bool) (*ssh.Client, string) { // get SSH config info, exit if not configured (displaying an error if the sync job was called manually) var sshUserConfig []string if manualSync { @@ -85,6 +92,13 @@ func GetSSHOutput(cmd string, manualSync bool) string { fmt.Println(backend.AnsiError+"Sync failed - Unable to connect to remote server:", err.Error()+backend.AnsiReset) os.Exit(1) } + + return sshClient, user +} + +// GetSSHOutput runs a command over SSH and returns the output as a string +func GetSSHOutput(cmd string, manualSync bool) string { + sshClient, _ := getSSHClient(manualSync) defer sshClient.Close() // create a session @@ -108,6 +122,35 @@ func GetSSHOutput(cmd string, manualSync bool) string { return outputString } +// scpTransfer uploads or downloads an entry over SCP // TODO consider using goroutines for multithreaded syncs +// WARNING: does not close sshClient; it is left open for further operations +// TODO does not yet support modification time preservation, needs https://github.com/bramvdbogaerde/go-scp/pull/81/commits +func scpTransfer(sshClient *ssh.Client, entryName, sshUser string, download bool) { + // create an SCP client TODO see if one can be passed in and reused + scpClient, err := scp.NewClientBySSH(sshClient) + + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - Unable to establish SCP session:", err.Error()+backend.AnsiReset) + os.Exit(1) + } + + // upload or download the entry + if download { + f, _ := os.Create(backend.EntryRoot + entryName) + defer f.Close() + err = scpClient.CopyFromRemote(context.Background(), f, backend.PathSeparator+"home"+backend.PathSeparator+sshUser+bareEntryRoot+entryName) // TODO support remote home directories other than "/home/$USER" (maybe fetch home directory form server during init, transparently to the user) + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - Unable to download entry:", entryName, err.Error()+backend.AnsiReset) + os.Exit(1) + + } + } else { + f, _ := os.Open(backend.EntryRoot + entryName) + defer f.Close() + err = scpClient.CopyFromFile(context.Background(), *f, backend.PathSeparator+"home"+backend.PathSeparator+sshUser+bareEntryRoot+entryName, "0600") + } +} + // getRemoteDataFromClient returns a map of remote entries to their modification times, a list of remote folders, and a list of queued deletions func getRemoteDataFromClient(manualSync bool) (map[string]int64, []string, []string) { // get remote output over SSH @@ -167,7 +210,10 @@ func getLocalData() map[string]int64 { // syncLists syncs entries between the client and server based on modification times // using maps means that syncing will be done in an arbitrary order, but it is a worthy tradeoff for speed and simplicity -func syncLists(localEntryModMap, remoteEntryModMap map[string]int64) { +func syncLists(localEntryModMap, remoteEntryModMap map[string]int64, manualSync bool) { + // establish an SSH connection for transfers TODO only establish if needed + sshClient, sshUser := getSSHClient(manualSync) + // iterate over client entries for entry, localModTime := range localEntryModMap { // check if the entry is present in the server map @@ -175,23 +221,23 @@ func syncLists(localEntryModMap, remoteEntryModMap map[string]int64) { // entry exists on both client and server, compare mod times if remoteModTime > localModTime { fmt.Println(ansiDownload+entry+backend.AnsiReset, "is newer on server, downloading...") - // TODO entry is newer on server, download + scpTransfer(sshClient, entry, sshUser, true) } else if remoteModTime < localModTime { fmt.Println(ansiUpload+entry+backend.AnsiReset, "is newer on client, uploading...") - // TODO entry is newer on client, upload + scpTransfer(sshClient, entry, sshUser, false) } // remove entry from remoteEntryModMap (process of elimination) delete(remoteEntryModMap, entry) } else { fmt.Println(ansiUpload+entry+backend.AnsiReset, "does not exist on server, uploading...") - // TODO entry does not exist on server, upload + scpTransfer(sshClient, entry, sshUser, false) } } // iterate over remaining entries in remoteEntryModMap for entry := range remoteEntryModMap { fmt.Println(ansiDownload+entry+backend.AnsiReset, "does not exist on client, downloading...") - // TODO entry does not exist on client, download + scpTransfer(sshClient, entry, sshUser, true) } } @@ -233,7 +279,7 @@ func RunJob(manualSync bool) { localEntryModMap := getLocalData() // sync new and updated entries - syncLists(localEntryModMap, remoteEntryModMap) + syncLists(localEntryModMap, remoteEntryModMap, manualSync) // exit program after successful sync os.Exit(0)