From 3dd07e7165874e323586c21a14853935bb3dc61c Mon Sep 17 00:00:00 2001 From: Randall Winkhart Date: Thu, 29 May 2025 23:39:15 -0400 Subject: [PATCH] Add offline mode option to libmutton.ini --- cfg/configParser.go | 6 +++++ core/init.go | 3 ++- syncclient/client.go | 55 +++++++++++++++++++++++--------------------- syncclient/oneOff.go | 36 +++++++++++++++++++---------- synccycles/init.go | 3 +-- 5 files changed, 62 insertions(+), 41 deletions(-) diff --git a/cfg/configParser.go b/cfg/configParser.go index 5df74c3..89f90aa 100644 --- a/cfg/configParser.go +++ b/cfg/configParser.go @@ -21,6 +21,7 @@ func loadConfig() (*ini.File, error) { // ParseConfig reads the libmutton.ini file and returns a slice of values for the specified keys. // Requires: valuesRequested (a slice of length 2 arrays each containing a section and a key name). // Returns: config (slice of values for the specified keys). +// If requesting SSH config, request "LIBMUTTON/offlineMode" first to avoid errors. func ParseConfig(valuesRequested [][2]string) ([]string, error) { cfg, err := loadConfig() if err != nil { @@ -35,6 +36,11 @@ func ParseConfig(valuesRequested [][2]string) ([]string, error) { return nil, fmt.Errorf("unable to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0]) } config = append(config, value) + + // notify requester immediately if in offline mode + if pair[0] == "LIBMUTTON" && pair[1] == "offlineMode" && value == "true" { + return config, nil + } } return config, err diff --git a/core/init.go b/core/init.go index 04eb03a..2d087ad 100644 --- a/core/init.go +++ b/core/init.go @@ -47,6 +47,7 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][ err = cfg.WriteConfig(append( clientSpecificIniData, [][3]string{ + {"LIBMUTTON", "offlineMode", "false"}, {"LIBMUTTON", "sshUser", sshUser}, {"LIBMUTTON", "sshIP", sshIP}, {"LIBMUTTON", "sshPort", sshPort}, @@ -74,7 +75,7 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][ } // write config file if len(clientSpecificIniData) > 0 { // TODO test passing empty clientSpecificIniData - err = cfg.WriteConfig(clientSpecificIniData, nil, false) + err = cfg.WriteConfig(append(clientSpecificIniData, [][3]string{{"LIBMUTTON", "offlineMode", "true"}}...), nil, false) if err != nil { return errors.New("unable to write config file: " + err.Error()) } diff --git a/syncclient/client.go b/syncclient/client.go index e61ffca..1ccafc1 100644 --- a/syncclient/client.go +++ b/syncclient/client.go @@ -17,13 +17,22 @@ import ( "golang.org/x/crypto/ssh/knownhosts" ) -// GetSSHClient returns an SSH client connection to the server (also returns the remote EntryRoot and an indicator of the server's OS). +// GetSSHClient +// Returns: +// sshClient, +// offlineMode (whether the client is in offline mode). +// sshIsWindows (whether the remote server is running Windows), +// sshEntryRoot (the root directory for entries on the remote server), // Only supports key-based authentication (passphrases are supported for CLI-based implementations). -func GetSSHClient() (*ssh.Client, string, bool, error) { - // get SSH config info, exit if not configured - sshUserConfig, err := cfg.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}) +func GetSSHClient() (*ssh.Client, bool, bool, string, error) { + // get SSH config info + sshUserConfig, err := cfg.ParseConfig([][2]string{{"LIBMUTTON", "offlineMode"}, {"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}) + if len(sshUserConfig) == 1 { + // offline mode is enabled + return nil, true, false, "", nil + } if err != nil { - return nil, "", false, errors.New("unable to parse SSH config: " + err.Error()) + return nil, false, false, "", errors.New("unable to parse SSH config: " + err.Error()) } var user, ip, port, keyFile, keyFileProtected, entryRoot string @@ -45,7 +54,7 @@ func GetSSHClient() (*ssh.Client, string, bool, error) { case 6: isWindows, err = strconv.ParseBool(key) if err != nil { - return nil, "", false, errors.New("unable to parse server OS type: " + err.Error()) + return nil, false, false, "", errors.New("unable to parse server OS type: " + err.Error()) } } } @@ -53,7 +62,7 @@ func GetSSHClient() (*ssh.Client, string, bool, error) { // read private key key, err := os.ReadFile(keyFile) if err != nil { - return nil, "", false, errors.New("unable to read private key: " + keyFile) + return nil, false, false, "", errors.New("unable to read private key: " + keyFile) } // parse private key @@ -64,14 +73,14 @@ func GetSSHClient() (*ssh.Client, string, bool, error) { parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, global.GetPassphrase("Enter passphrase for your SSH keyfile:")) } if err != nil { - return nil, "", false, errors.New("unable to parse private key: " + keyFile) + return nil, false, false, "", errors.New("unable to parse private key: " + keyFile) } // read known hosts file var hostKeyCallback ssh.HostKeyCallback hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts") if err != nil { - return nil, "", false, errors.New("unable to read known hosts file: " + err.Error()) + return nil, false, false, "", errors.New("unable to read known hosts file: " + err.Error()) } // configure SSH client @@ -87,10 +96,10 @@ func GetSSHClient() (*ssh.Client, string, bool, error) { // connect to SSH server sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig) if err != nil { - return nil, "", false, errors.New("unable to connect to remote server: " + err.Error()) + return nil, false, false, "", errors.New("unable to connect to remote server: " + err.Error()) } - return sshClient, entryRoot, isWindows, nil + return sshClient, false, isWindows, entryRoot, nil } // GetSSHOutput runs a command over SSH and returns the output as a string. @@ -119,18 +128,14 @@ func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) (string, error) { } // getRemoteDataFromClient returns a map of remote entries to their modification times, a list of remote folders, a list of queued deletions, and the current server&client times as UNIX timestamps. -func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string]int64, []string, []string, int64, int64, error) { +func getRemoteDataFromClient(sshClient *ssh.Client) (map[string]int64, []string, []string, int64, int64, error) { // get remote output over SSH deviceIDList, err := global.GenDeviceIDList() if err != nil { return nil, nil, nil, 0, 0, err } if len(deviceIDList) == 0 { - if manualSync { - return nil, nil, nil, 0, 0, errors.New("no device ID found") - } else { - back.Exit(0) // exit silently if the sync job was called automatically, as the user may just be in offline mode - } + return nil, nil, nil, 0, 0, errors.New("no device ID found") } clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time output, err := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name()) @@ -427,24 +432,22 @@ func folderSync(folders []string) error { } // RunJob runs the SSH sync job. -// Setting manualSync to true will throw errors if sync is not configured (online mode is assumed). // Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client. -func RunJob(manualSync, returnLists bool) ([3][]string, error) { +func RunJob(returnLists bool) ([3][]string, error) { // get SSH client to re-use throughout the sync process - sshClient, sshEntryRoot, sshIsWindows, err := GetSSHClient() + sshClient, offlineMode, sshIsWindows, sshEntryRoot, err := GetSSHClient() + if offlineMode { + return [3][]string{nil, nil, nil}, nil + } if err != nil { - if manualSync { - return [3][]string{nil, nil, nil}, errors.New("unable to connect to SSH client: " + err.Error()) - } else { - return [3][]string{nil, nil, nil}, nil // return silently if the sync job was called automatically, as the user may just be in offline mode - } + return [3][]string{nil, nil, nil}, errors.New("unable to connect to SSH client: " + err.Error()) } defer func(sshClient *ssh.Client) { _ = sshClient.Close() }(sshClient) // fetch remote lists - remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient, manualSync) + remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient) if err != nil { return [3][]string{nil, nil, nil}, errors.New("unable to fetch remote data: " + err.Error()) } diff --git a/syncclient/oneOff.go b/syncclient/oneOff.go index a28d287..8391a18 100644 --- a/syncclient/oneOff.go +++ b/syncclient/oneOff.go @@ -11,15 +11,18 @@ import ( // ShearRemoteFromClient removes the target file or directory from the local system and calls the server to remove it remotely and add it to the deletions list. // It can safely be called in offline mode, as well, so this is the intended interface for shearing (ShearLocal should only be used directly by the server binary). -func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) error { +func ShearRemoteFromClient(targetLocationIncomplete string) error { deviceID, isDir, err := synccommon.ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client if err != nil { return errors.New("unable to shear target locally: " + err.Error()) } - if !forceOffline && deviceID != "" { // ensure a device ID exists (online mode) - // create an SSH client; manualSync is false in case a device ID exists but SSH is not configured - sshClient, _, _, err := GetSSHClient() + if deviceID != "" { // ensure a device ID exists (online mode) + // create an SSH client + sshClient, offlineMode, _, _, err := GetSSHClient() + if offlineMode { + goto end + } if err != nil { return errors.New("unable to connect to SSH client: " + err.Error()) } @@ -42,13 +45,14 @@ func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) e } } +end: back.Exit(0) // sync is not required after shearing since the target has already been removed from the local system return nil } // RenameRemoteFromClient renames oldLocationIncomplete to newLocationIncomplete on the local system and calls the server to perform the rename remotely and add the old target to the deletions list. // It can safely be called in offline mode, as well, so this is the intended interface for renaming (RenameLocal should only be used directly by the server binary). -func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, forceOffline bool) error { +func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string) error { err := synccommon.RenameLocal(oldLocationIncomplete, newLocationIncomplete, false) // move the target on the local system if err != nil { return errors.New("unable to rename target locally: " + err.Error()) @@ -58,9 +62,12 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, if err != nil { return errors.New("unable to generate device ID list: " + err.Error()) } - if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode) - // create an SSH client; manualSync is false in case a device ID exists but SSH is not configured - sshClient, _, _, err := GetSSHClient() + if len(deviceIDList) > 0 { // ensure a device ID exists (online mode) + // create an SSH client + sshClient, offlineMode, _, _, err := GetSSHClient() + if offlineMode { + goto end + } if err != nil { return errors.New("unable to connect to SSH client: " + err.Error()) } @@ -81,13 +88,14 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, } } +end: back.Exit(0) return nil } // AddFolderRemoteFromClient creates a new entry-containing directory on the local system and calls the server to create the folder remotely. // It can safely be called in offline mode, as well, so this is the intended interface for adding folders (AddFolderLocal should only be used directly by the server binary). -func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline bool) error { +func AddFolderRemoteFromClient(targetLocationIncomplete string) error { err := synccommon.AddFolderLocal(targetLocationIncomplete) // add the folder on the local system if err != nil { return errors.New("unable to add folder locally: " + err.Error()) @@ -97,9 +105,12 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline boo if err != nil { return errors.New("unable to generate device ID list: " + err.Error()) } - if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode) - // create an SSH client; manualSync is false in case a device ID exists but SSH is not configured - sshClient, _, _, err := GetSSHClient() + if len(deviceIDList) > 0 { // ensure a device ID exists (online mode) + // create an SSH client + sshClient, offlineMode, _, _, err := GetSSHClient() + if offlineMode { + goto end + } if err != nil { return errors.New("unable to connect to SSH client: " + err.Error()) } @@ -117,6 +128,7 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline boo } } +end: back.Exit(0) return nil } diff --git a/synccycles/init.go b/synccycles/init.go index 32a657c..7cd32cc 100644 --- a/synccycles/init.go +++ b/synccycles/init.go @@ -37,8 +37,7 @@ func DeviceIDGen(oldDeviceID string) (string, string, error) { // register new device ID with server and fetch remote EntryRoot and OS type // also removes the old device ID file (remotely) - // manualSync is true so the user is alerted if device ID registration fails - sshClient, _, _, err := syncclient.GetSSHClient() + sshClient, _, _, _, err := syncclient.GetSSHClient() if err != nil { return "", "", errors.New("unable to connect to SSH client: " + err.Error()) }