diff --git a/src/cli/init.go b/src/cli/init.go index 0c3852d..f08ad94 100644 --- a/src/cli/init.go +++ b/src/cli/init.go @@ -33,7 +33,7 @@ func TempInitCli() { // client device ID deviceIDPrefix, _ := os.Hostname() deviceIDSuffix := backend.StringGen(rand.Intn(48)+48, true, 0.2) - deviceID := deviceIDPrefix + "-" + deviceIDSuffix // TODO do not save in config file, encrypt in config directory + deviceID := deviceIDPrefix + "-" + deviceIDSuffix // necessary SSH info sshUser := input("Remote SSH username:") diff --git a/src/sync/sync.go b/src/sync/sync.go index f6c08a7..73230ea 100644 --- a/src/sync/sync.go +++ b/src/sync/sync.go @@ -3,17 +3,15 @@ package sync import ( "fmt" "github.com/rwinkhart/MUTN/src/backend" + "golang.org/x/crypto/ssh" "os" + "strconv" + "strings" ) -// returns lists of local entries and mod times (two separate lists) -func getLocalData() { - fileList, _ := WalkEntryDir() - fmt.Println(fileList) // TODO placeholder -} - -// RunJob runs the SSH sync job -func RunJob(manualSync bool) { +// getSSHOutput runs a command over SSH and returns the output +// currently only supports password-less key-based authentication TODO add password support, still require key +func getSSHOutput(cmd string, manualSync bool) string { // get SSH config info, exit if not configured (displaying an error if the sync job was called manually) var sshUserIPPortIdentity []string if manualSync { @@ -22,21 +20,126 @@ func RunJob(manualSync bool) { sshUserIPPortIdentity = backend.ReadConfig([]string{"sshUser", "sshIP", "sshPort", "sshIdentity"}, "0") } - var sshUser, sshIP, sshPort, sshIdentity string + var user, ip, port, identity string for i, key := range sshUserIPPortIdentity { switch i { case 0: - sshUser = key + user = key case 1: - sshIP = key + ip = key case 2: - sshPort = key + port = key case 3: - sshIdentity = key + identity = key } } - fmt.Println(sshUser, sshIP, sshPort, sshIdentity) // TODO placeholder + // read and parse private key + key, err := os.ReadFile(identity) + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - unable to read private key file:", identity+backend.AnsiReset) + os.Exit(1) + } + parsedKey, err := ssh.ParsePrivateKey(key) + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - Unable to parse private key:", identity+backend.AnsiReset) + os.Exit(1) + } + // configure SSH client + sshConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(parsedKey), + }, + } + sshConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey() // TODO remove + + // connect to SSH server + sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig) + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - Unable to connect to remote server:", err.Error()+backend.AnsiReset) + os.Exit(1) + } + defer sshClient.Close() + + // create a session + sshSession, err := sshClient.NewSession() + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - Unable to establish SSH session:", err.Error()+backend.AnsiReset) + os.Exit(1) + } + + // run the provided command + output, err := sshSession.CombinedOutput(cmd) + if err != nil { + fmt.Println(backend.AnsiError+"Sync failed - Unable to run SSH command:", err.Error()+backend.AnsiReset) + os.Exit(1) + } + + // convert the output to a string and remove leading/trailing whitespace + outputString := string(output) + outputString = strings.TrimSpace(outputString) + + return outputString +} + +// getRemoteData returns lists of remote entries, mod times, folders, and deletions (four separate lists) +func getRemoteData(manualSync bool) ([]string, []int64, []string, []string) { + // get remote output over SSH + output := getSSHOutput("ls && uname -r", manualSync) // TODO replace cmd with remote server command (e.g. "libmuttonserver fetch") + + // split output into slice based on occurrences of "\x1f" + outputSlice := strings.Split(output, "\x1f") + + // re-form the lists + entries := strings.Split(outputSlice[0], "\n") + modsStrings := strings.Split(outputSlice[1], "\n") + folders := strings.Split(outputSlice[2], "\n") + deletions := strings.Split(outputSlice[3], "\n") + + // convert the mod times to int64 + var mods []int64 + for _, modString := range modsStrings { + mod, _ := strconv.ParseInt(modString, 10, 64) + mods = append(mods, mod) + } + + return entries, mods, folders, deletions +} + +// getLocalData returns lists of local entries and mod times (two separate lists) +func getLocalData() ([]string, []int64) { + // get a list of all entries + fileList, _ := WalkEntryDir() + + // get a list of all entry modification times + var modList []int64 + for _, file := range fileList { + modTime, _ := os.Stat(backend.EntryRoot + file) + modList = append(modList, modTime.ModTime().Unix()) + } + + // return the lists + return fileList, modList +} + +// RunJob runs the SSH sync job +func RunJob(manualSync bool) { + // TODO fetch remote lists + remoteEntries, remoteMods, remoteFolders, remoteDeletions := getRemoteData(manualSync) + fmt.Println(remoteEntries, remoteMods, remoteFolders, remoteDeletions) // TODO placeholder + + // TODO sync deletions and folders + + // fetch local lists + localEntries, localMods := getLocalData() + fmt.Println(localEntries, localMods) // TODO placeholder + + // TODO sort both local and remote entry/mod lists to ensure common entries are listed first + + // TODO sync new and updated entries + + // exit program after successful sync os.Exit(0) }