Add offline mode option to libmutton.ini

This commit is contained in:
2025-05-29 23:39:15 -04:00
parent 9ffd6d5cef
commit 3dd07e7165
5 changed files with 62 additions and 41 deletions
+6
View File
@@ -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. // 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). // 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). // 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) { func ParseConfig(valuesRequested [][2]string) ([]string, error) {
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { 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]) 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) 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 return config, err
+2 -1
View File
@@ -47,6 +47,7 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][
err = cfg.WriteConfig(append( err = cfg.WriteConfig(append(
clientSpecificIniData, clientSpecificIniData,
[][3]string{ [][3]string{
{"LIBMUTTON", "offlineMode", "false"},
{"LIBMUTTON", "sshUser", sshUser}, {"LIBMUTTON", "sshUser", sshUser},
{"LIBMUTTON", "sshIP", sshIP}, {"LIBMUTTON", "sshIP", sshIP},
{"LIBMUTTON", "sshPort", sshPort}, {"LIBMUTTON", "sshPort", sshPort},
@@ -74,7 +75,7 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][
} }
// write config file // write config file
if len(clientSpecificIniData) > 0 { // TODO test passing empty clientSpecificIniData 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 { if err != nil {
return errors.New("unable to write config file: " + err.Error()) return errors.New("unable to write config file: " + err.Error())
} }
+29 -26
View File
@@ -17,13 +17,22 @@ import (
"golang.org/x/crypto/ssh/knownhosts" "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). // Only supports key-based authentication (passphrases are supported for CLI-based implementations).
func GetSSHClient() (*ssh.Client, string, bool, error) { func GetSSHClient() (*ssh.Client, bool, bool, string, error) {
// get SSH config info, exit if not configured // get SSH config info
sshUserConfig, err := cfg.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}) 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 { 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 var user, ip, port, keyFile, keyFileProtected, entryRoot string
@@ -45,7 +54,7 @@ func GetSSHClient() (*ssh.Client, string, bool, error) {
case 6: case 6:
isWindows, err = strconv.ParseBool(key) isWindows, err = strconv.ParseBool(key)
if err != nil { 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 // read private key
key, err := os.ReadFile(keyFile) key, err := os.ReadFile(keyFile)
if err != nil { 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 // 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:")) parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, global.GetPassphrase("Enter passphrase for your SSH keyfile:"))
} }
if err != nil { 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 // read known hosts file
var hostKeyCallback ssh.HostKeyCallback var hostKeyCallback ssh.HostKeyCallback
hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts") hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts")
if err != nil { 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 // configure SSH client
@@ -87,10 +96,10 @@ func GetSSHClient() (*ssh.Client, string, bool, error) {
// connect to SSH server // connect to SSH server
sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig) sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig)
if err != nil { 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. // 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. // 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 // get remote output over SSH
deviceIDList, err := global.GenDeviceIDList() deviceIDList, err := global.GenDeviceIDList()
if err != nil { if err != nil {
return nil, nil, nil, 0, 0, err return nil, nil, nil, 0, 0, err
} }
if len(deviceIDList) == 0 { if len(deviceIDList) == 0 {
if manualSync { return nil, nil, nil, 0, 0, errors.New("no device ID found")
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
}
} }
clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time 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()) output, err := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name())
@@ -427,24 +432,22 @@ func folderSync(folders []string) error {
} }
// RunJob runs the SSH sync job. // 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. // 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 // 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 err != nil {
if manualSync { return [3][]string{nil, nil, nil}, errors.New("unable to connect to SSH client: " + err.Error())
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
}
} }
defer func(sshClient *ssh.Client) { defer func(sshClient *ssh.Client) {
_ = sshClient.Close() _ = sshClient.Close()
}(sshClient) }(sshClient)
// fetch remote lists // fetch remote lists
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient, manualSync) remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient)
if err != nil { if err != nil {
return [3][]string{nil, nil, nil}, errors.New("unable to fetch remote data: " + err.Error()) return [3][]string{nil, nil, nil}, errors.New("unable to fetch remote data: " + err.Error())
} }
+24 -12
View File
@@ -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. // 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). // 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 deviceID, isDir, err := synccommon.ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
if err != nil { if err != nil {
return errors.New("unable to shear target locally: " + err.Error()) return errors.New("unable to shear target locally: " + err.Error())
} }
if !forceOffline && deviceID != "" { // ensure a device ID exists (online mode) if 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 // create an SSH client
sshClient, _, _, err := GetSSHClient() sshClient, offlineMode, _, _, err := GetSSHClient()
if offlineMode {
goto end
}
if err != nil { if err != nil {
return errors.New("unable to connect to SSH client: " + err.Error()) 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 back.Exit(0) // sync is not required after shearing since the target has already been removed from the local system
return nil 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. // 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). // 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 err := synccommon.RenameLocal(oldLocationIncomplete, newLocationIncomplete, false) // move the target on the local system
if err != nil { if err != nil {
return errors.New("unable to rename target locally: " + err.Error()) return errors.New("unable to rename target locally: " + err.Error())
@@ -58,9 +62,12 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string,
if err != nil { if err != nil {
return errors.New("unable to generate device ID list: " + err.Error()) return errors.New("unable to generate device ID list: " + err.Error())
} }
if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode) if 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 // create an SSH client
sshClient, _, _, err := GetSSHClient() sshClient, offlineMode, _, _, err := GetSSHClient()
if offlineMode {
goto end
}
if err != nil { if err != nil {
return errors.New("unable to connect to SSH client: " + err.Error()) return errors.New("unable to connect to SSH client: " + err.Error())
} }
@@ -81,13 +88,14 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string,
} }
} }
end:
back.Exit(0) back.Exit(0)
return nil return nil
} }
// AddFolderRemoteFromClient creates a new entry-containing directory on the local system and calls the server to create the folder remotely. // 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). // 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 err := synccommon.AddFolderLocal(targetLocationIncomplete) // add the folder on the local system
if err != nil { if err != nil {
return errors.New("unable to add folder locally: " + err.Error()) return errors.New("unable to add folder locally: " + err.Error())
@@ -97,9 +105,12 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline boo
if err != nil { if err != nil {
return errors.New("unable to generate device ID list: " + err.Error()) return errors.New("unable to generate device ID list: " + err.Error())
} }
if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode) if 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 // create an SSH client
sshClient, _, _, err := GetSSHClient() sshClient, offlineMode, _, _, err := GetSSHClient()
if offlineMode {
goto end
}
if err != nil { if err != nil {
return errors.New("unable to connect to SSH client: " + err.Error()) 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) back.Exit(0)
return nil return nil
} }
+1 -2
View File
@@ -37,8 +37,7 @@ func DeviceIDGen(oldDeviceID string) (string, string, error) {
// register new device ID with server and fetch remote EntryRoot and OS type // register new device ID with server and fetch remote EntryRoot and OS type
// also removes the old device ID file (remotely) // 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 { if err != nil {
return "", "", errors.New("unable to connect to SSH client: " + err.Error()) return "", "", errors.New("unable to connect to SSH client: " + err.Error())
} }