48 Commits
Author SHA1 Message Date
RandyTheSilly d35c9c2186 Prepare for release v0.3.0 2025-02-22 16:50:12 -05:00
RandyTheSilly d70bd62372 Bump golang.org/x/crypto to v0.34.0 2025-02-22 16:08:37 -05:00
RandyTheSilly e1222f94d9 Bump Go to v1.24.0; bump dependencies (golang.org/x/crypto, golang.org/x/sys) 2025-02-15 13:03:22 -05:00
RandyTheSilly e9d0c37043 Implement utility function for formatting and printing errors 2025-02-01 20:48:03 -05:00
RandyTheSilly 173574eb85 Remove error messages that do not make sense for GUI implementations 2025-02-01 19:31:24 -05:00
RandyTheSilly 4689cae3f4 Ensure sheared folders always end in a trailing slash in the deletions directory; this improves clarity and reduces duplicate entries in the deletions directory 2025-02-01 12:00:34 -05:00
RandyTheSilly eb2b349697 Sync deletions before folders to avoid failing to download folder contents if the deleted folder is re-added prior to sync 2025-01-26 18:12:54 -05:00
RandyTheSilly 038e386df7 Return nil instead of an empty slice in core.RemoveTrailingEmptyStrings 2025-01-18 21:20:39 -05:00
RandyTheSilly fc1cd349b8 Add function for clamping trailing whitespace to allow for double-space line breaks with Markdown formatting 2025-01-18 21:09:19 -05:00
RandyTheSilly fb5449cf1f Remove poorly placed "verifyEntryDoesNotExist" check, as clients should be responsible for performing this check long before attempting to write the entry 2025-01-18 19:29:15 -05:00
RandyTheSilly 55be8a3075 Return immediately after clearing clipboard (useful for GUI/TUI clients) 2025-01-16 15:57:34 -05:00
RandyTheSilly c71cacb507 Fix build failures on several platforms 2025-01-16 15:49:57 -05:00
RandyTheSilly 81b78068a7 Add exported ExpandPathWithHome utility 2025-01-16 14:22:07 -05:00
RandyTheSilly 89b74cec1e Allow removing keys from config file 2025-01-16 13:47:30 -05:00
RandyTheSilly 0162b737c8 Fix crash for SSH dialing errors on interactive clients 2025-01-12 21:16:21 -05:00
RandyTheSilly 1c650d4751 Avoid hard closing/crashing interative clients on SSH dialing failures 2025-01-12 20:47:20 -05:00
RandyTheSilly 5028fb21b0 Add 3-second timeout for SSH connection attempts 2025-01-12 20:35:43 -05:00
RandyTheSilly 4ca97834f4 Fix formatting of copyright date in version info output 2025-01-11 18:11:27 -05:00
RandyTheSilly b37dfdb912 Bump copyright date 2025-01-11 17:58:46 -05:00
RandyTheSilly cdb2318dc1 Bump golang.org/x/crypto to v0.32.0; bump golang.org/x/sys to v0.29.0 2025-01-11 17:49:44 -05:00
RandyTheSilly 68e3108741 Allow returning lists of synchronized entries from sync.RunJob 2025-01-11 17:47:27 -05:00
RandyTheSilly b5dd8ec502 Specify that termux binaries should target Android 2024-12-29 19:49:41 -05:00
RandyTheSilly d2034b458b Export WriteToStdin for use by clients 2024-12-29 19:38:05 -05:00
RandyTheSilly fc2e77d8b8 Bump version constant to "0.D.0" to indicate development version of v0.3.0 2024-12-29 16:28:18 -05:00
RandyTheSilly c28d45c9da Allow forcing one-off functions in sync package to operate in offline mode 2024-12-29 16:25:13 -05:00
RandyTheSilly 2feeeb74d7 Return errors from ParseConfig (facilitates GUI development) 2024-12-29 16:23:08 -05:00
RandyTheSilly 1a1d4ed1e4 Fix typo in ParseConfig documentation 2024-12-28 19:53:14 -05:00
RandyTheSilly a4a39a3a99 Support passphrase-protected SSH identity files on non-CLI clients 2024-12-28 15:09:17 -05:00
RandyTheSilly 1b9237af0b Fix support for non-Linux/BSD platforms; rename file for clarity 2024-12-21 17:30:42 -05:00
RandyTheSilly 049af83390 Rename "returnOnExit" build tag to "interactive" 2024-12-21 16:07:45 -05:00
RandyTheSilly f9aa2fc374 Export LaunchClipClearProcess; use more reliable method to manipulate os.Args for GUI clients 2024-12-21 16:05:04 -05:00
RandyTheSilly 3b57d59ddc Allow clearing the clipboard instantly; improve code sharing between platforms; facilitate GUI/TUI client development regarding the clipboard 2024-12-21 15:31:14 -05:00
RandyTheSilly 9ed7b8ad9b Fix typo in README.md 2024-12-20 20:55:16 -05:00
RandyTheSilly a4864b6544 Specify "clipclear" now only needs to be accepted for non-interactive CLI implementations 2024-12-20 20:49:54 -05:00
RandyTheSilly 619e4220a6 Implement unique clipboard clearing behavior for interactive GUI/TUI implementations 2024-12-20 20:29:16 -05:00
RandyTheSilly f2f9c523f4 Do not declare outputSlice variable in DecryptGPG 2024-12-20 19:44:36 -05:00
RandyTheSilly 1cebb39546 Fix soft exit function not specifying a return value 2024-12-16 13:10:07 -05:00
RandyTheSilly 1db9467446 Prepare for release v0.2.4 2024-12-13 12:17:08 -05:00
RandyTheSilly 3e966ade11 Bump golang.org/x/crypto to v0.31.0 to address CVE-2024-45337 2024-12-13 12:00:55 -05:00
RandyTheSilly dd6bde5e4d Bump Go to v1.23.4; bump dependencies 2024-12-10 16:47:00 -05:00
RandyTheSilly 91fdeb53d5 Do not infer MIME type when copying data on UNIX-like systems 2024-12-10 16:28:42 -05:00
RandyTheSilly d613ace494 Remove commit.sh 2024-10-29 17:18:35 -04:00
RandyTheSilly 003c373cc8 Update roadmap 2024-10-19 17:05:10 -04:00
RandyTheSilly cdb58e9984 Add "1output" to .gitignore; add Go bump to patch notes 2024-10-19 16:27:28 -04:00
RandyTheSilly 4f923e7272 Prepare for release v0.2.3 2024-10-19 16:20:34 -04:00
RandyTheSilly fcde9689c8 Bump dependencies 2024-10-19 16:11:56 -04:00
RandyTheSilly ab3f6be41b Bump Go to v1.23.2 2024-10-19 16:07:39 -04:00
RandyTheSilly 1fb2834844 Fix renaming an entry/folder to an invalid path still attempting the rename on the server 2024-10-18 12:34:17 -04:00
38 changed files with 503 additions and 469 deletions
+1
View File
@@ -4,3 +4,4 @@
/libmuttonserver.exe
/main
/main.exe
1output
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Randall Winkhart
Copyright (c) 2024-2025 Randall Winkhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+5 -6
View File
@@ -8,19 +8,18 @@ libmutton is a library for building simple, SSH-synchronized password managers i
>I am not responsible for any data loss or breaches of your information resulting from the use of libmutton.
>libmutton is a new project that is constantly being updated, and though safety and security are priorities, they cannot be guaranteed.
# Developing Thid-Party Clients
# Developing Third-Party Clients
See the [developer guide](https://github.com/rwinkhart/libmutton/blob/main/wiki/developers.md).
# Roadmap
#### Release v0.3.0
- [ ] Swap to native (cascade) encryption (custom)
- [ ] Implement "netpin" (quick-unlock) with new encryption
- [ ] Add refresh/re-encrypt functionality
#### Release v0.4.0
- [ ] Password aging support
- [ ] Append UNIX timestamp to entry names
- [ ] Add yellow/red dot indicators to entry list readout for when passwords should be changed
#### Release v0.5.0
- [ ] Add refresh/re-encrypt functionality
- [ ] Swap to native (cascade) encryption (custom)
- [ ] Implement "netpin" (quick-unlock) with new encryption
#### Release v1.0.0
- [ ] Create packaging scripts (libmuttonserver)
- [ ] Stable source PKGBUILD
@@ -29,4 +28,4 @@ See the [developer guide](https://github.com/rwinkhart/libmutton/blob/main/wiki/
- [ ] Fedora
- [ ] FreeBSD
- [ ] Windows installer
- Hunt for polishing opportunities and bugs
- Perform extensive testing
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
gofmt -l -w -s ./core/*.go ./sync/*.go ./libmuttonserver.go
git commit -am "$1"
git push
+8 -5
View File
@@ -4,16 +4,19 @@ import (
"os"
)
type ByteInputFetcher func(prompt string) []byte
var (
Home, _ = os.UserHomeDir()
PassphraseInputFunction ByteInputFetcher // Clients should set this to a function that fetches hidden input from the user
Home, _ = os.UserHomeDir()
)
const (
LibmuttonVersion = "0.2.2" // untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.2.A" -> "0.2.0", "0.2.B" -> "0.2.1"
LibmuttonVersion = "0.3.0" // Untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.2.A" -> "0.2.0", "0.2.B" -> "0.2.1"
FSSpace = "\u259d" // ▝ space/list separator
FSPath = "\u259e" // ▞ path separator
FSMisc = "\u259f" // ▟ misc. field separator (if \u259d is already used)
FSSpace = "\u259d" // ▝ Space/list separator
FSPath = "\u259e" // ▞ Path separator
FSMisc = "\u259f" // ▟ Misc. field separator (if \u259d is already used)
AnsiError = "\033[38;5;9m"
AnsiReset = "\033[0m"
+5 -5
View File
@@ -2,13 +2,13 @@
package core
var EntryRoot = Home + "/.local/share/libmutton" // path to libmutton entry directory
var ConfigDir = Home + "/.config/libmutton" // path to libmutton configuration directory
var ConfigPath = ConfigDir + "/libmutton.ini" // path to libmutton configuration file
var EntryRoot = Home + "/.local/share/libmutton" // Path to libmutton entry directory
var ConfigDir = Home + "/.config/libmutton" // Path to libmutton configuration directory
var ConfigPath = ConfigDir + "/libmutton.ini" // Path to libmutton configuration file
const (
PathSeparator = "/" // platform-specific path separator
IsWindows = false // platform indicator
PathSeparator = "/" // Platform-specific path separator
IsWindows = false // Platform indicator
)
// enableVirtualTerminalProcessing is a dummy function on UNIX-like systems (only needed on Windows).
+5 -5
View File
@@ -7,13 +7,13 @@ import (
"syscall"
)
var EntryRoot = Home + "\\AppData\\Local\\libmutton\\entries" // path to libmutton entry directory
var ConfigDir = Home + "\\AppData\\Local\\libmutton\\config" // path to libmutton configuration directory
var ConfigPath = ConfigDir + "\\libmutton.ini" // path to libmutton configuration file
var EntryRoot = Home + "\\AppData\\Local\\libmutton\\entries" // Path to libmutton entry directory
var ConfigDir = Home + "\\AppData\\Local\\libmutton\\config" // Path to libmutton configuration directory
var ConfigPath = ConfigDir + "\\libmutton.ini" // Path to libmutton configuration file
const (
PathSeparator = "\\" // platform-specific path separator
IsWindows = true // platform indicator
PathSeparator = "\\" // Platform-specific path separator
IsWindows = true // Platform indicator
)
// enableVirtualTerminalProcessing ensures ANSI escape sequences are interpreted properly on Windows.
+27 -16
View File
@@ -13,17 +13,18 @@ import (
func loadConfig() *ini.File {
cfg, err := ini.Load(ConfigPath)
if err != nil {
fmt.Println(AnsiError+"Failed to load libmutton.ini:", err.Error()+AnsiReset)
os.Exit(ErrorRead)
PrintError("Failed to load libmutton.ini: "+err.Error(), ErrorRead, true)
}
return cfg
}
// ParseConfig reads the libmutton.ini file and returns a slice of values for the specified keys.
// Requires: requestedValues (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),
// missingValueError (an error message to display if a key is missing a value, set to "" for auto-generated or "0" to exit/return silently with code 0).
// Returns: config (slice of values for the specified keys).
func ParseConfig(valuesRequested [][2]string, missingValueError string) []string {
// Returns: config (slice of values for the specified keys),
// error (nil if no error occurred, otherwise an error using the generated or provided message).
func ParseConfig(valuesRequested [][2]string, missingValueError string) ([]string, error) {
var err error
cfg := loadConfig()
var config []string
@@ -35,19 +36,21 @@ func ParseConfig(valuesRequested [][2]string, missingValueError string) []string
if value == "" {
switch missingValueError {
case "":
fmt.Println(AnsiError + "Failed to find value for key \"" + pair[1] + "\" in section \"[" + pair[0] + "]\" in libmutton.ini" + AnsiReset)
err = fmt.Errorf("Failed to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0])
case "0":
Exit(0)
Exit(0) // hard (expected) exit for CLI; GUI/TUI continue silently
default:
fmt.Println(AnsiError + missingValueError + AnsiReset)
err = fmt.Errorf("%s", missingValueError)
}
os.Exit(ErrorRead)
PrintError(err.Error(), ErrorRead, false)
// if interactive (soft exit), return nil and the error to be handled by the caller
return nil, err
}
config = append(config, value)
}
return config
return config, err
}
// GenDeviceIDList returns a pointer to a slice of all registered device IDs.
@@ -57,8 +60,7 @@ func GenDeviceIDList(errorOnFail bool) *[]fs.DirEntry {
deviceIDList, err := os.ReadDir(ConfigDir + PathSeparator + "devices")
if err != nil {
if errorOnFail {
fmt.Println(AnsiError+"Failed to read the devices directory:", err.Error()+AnsiReset)
os.Exit(ErrorRead)
PrintError("Failed to read the devices directory: "+err.Error(), ErrorRead, true)
} else {
return nil // a nil return value indicates that the devices directory could not be read/does not exist
}
@@ -67,8 +69,10 @@ func GenDeviceIDList(errorOnFail bool) *[]fs.DirEntry {
}
// WriteConfig writes the provided key-value pairs under the specified section headers in the libmutton.ini file.
// Requires: valuesToWrite (a slice of length 3 arrays each containing a section, a key name, and a value).
func WriteConfig(valuesToWrite [][3]string, append bool) {
// Requires: valuesToWrite (a slice of length 3 arrays each containing a section, a key name, and a value),
// prune (a slice similar to valuesToWrite to allow removing the specified keys from an existing config)
// append (set to true to append to the existing libmutton.ini file, false to overwrite it).
func WriteConfig(valuesToWrite [][3]string, keysToPrune [][2]string, append bool) {
var cfg *ini.File
if append {
@@ -94,10 +98,17 @@ func WriteConfig(valuesToWrite [][3]string, append bool) {
section.Key(trio[1]).SetValue(trio[2])
}
// prune specified keys from the existing config
if append && len(keysToPrune) > 0 {
// remove specified keys pairs from the existing config
for _, pair := range keysToPrune {
cfg.Section(pair[0]).DeleteKey(pair[1])
}
}
// save to libmutton.ini
err := cfg.SaveTo(ConfigPath)
if err != nil {
fmt.Println(AnsiError+"Failed to save libmutton.ini:", err.Error()+AnsiReset)
os.Exit(ErrorWrite)
PrintError("Failed to save libmutton.ini: "+err.Error(), ErrorWrite, true)
}
}
+42 -13
View File
@@ -12,7 +12,7 @@ import (
)
// CopyArgument copies a field from an entry to the clipboard.
func CopyArgument(executableName, targetLocation string, field int) {
func CopyArgument(targetLocation string, field int) {
if isFile, _ := TargetIsFile(targetLocation, true, 2); isFile {
decryptedEntry := DecryptGPG(targetLocation)
@@ -23,8 +23,7 @@ func CopyArgument(executableName, targetLocation string, field int) {
// ensure field is not empty
if decryptedEntry[field] == "" {
fmt.Println(AnsiError + "Field is empty" + AnsiReset)
os.Exit(ErrorTargetNotFound)
PrintError("Field is empty", ErrorTargetNotFound, true)
}
if field != 2 {
@@ -42,35 +41,66 @@ func CopyArgument(executableName, targetLocation string, field int) {
fmt.Println("Clipboard will be kept up to date with the current TOTP code until this process is closed")
for { // keep field copied to clipboard, refresh on 30-second intervals
for { // keep token copied to clipboard, refresh on 30-second intervals
currentTime := time.Now()
copyField("", GenTOTP(secret, currentTime, forSteam))
copyString(true, GenTOTP(secret, currentTime, forSteam))
// sleep until next 30-second interval
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
}
}
} else {
fmt.Println(AnsiError + "Field does not exist in entry" + AnsiReset)
os.Exit(ErrorTargetNotFound)
PrintError("Field does not exist in entry", ErrorTargetNotFound, true)
}
// copy field to clipboard, launch clipboard clearing process
copyField(executableName, copySubject)
copyString(false, copySubject)
}
}
// ClipClearArgument is called to clear the clipboard after 30 seconds if the contents have not been modified.
// ClipClearArgument reads the assigned clipboard contents from stdin and passes them to clipClearProcess.
func ClipClearArgument() {
// read previous clipboard contents from stdin
clipScanner := bufio.NewScanner(os.Stdin)
if clipScanner.Scan() {
oldContents := clipScanner.Text()
clipClear(oldContents)
assignedContents := clipScanner.Text()
clipClearProcess(assignedContents)
} else {
os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
}
}
// clipClearProcess clears the clipboard after 30 seconds if the clipboard contents have not changed.
// assignedContents can be omitted to clear the clipboard immediately and unconditionally.
func clipClearProcess(assignedContents string) {
cmdPaste, cmdClear := getClipCommands()
clearClipboard := func() {
err := cmdClear.Run()
if err != nil {
PrintError("Failed to clear clipboard", ErrorClipboard, true)
}
Exit(0)
}
// if assignedContents is empty, clear the clipboard immediately and unconditionally
if assignedContents == "" {
clearClipboard()
return
}
// wait 30 seconds before checking clipboard contents
time.Sleep(30 * time.Second)
newContents, err := cmdPaste.Output()
if err != nil {
PrintError("Failed to read clipboard contents", ErrorClipboard, true)
}
if assignedContents == strings.TrimRight(string(newContents), "\r\n") {
clearClipboard()
}
}
// GenTOTP generates a TOTP token from a secret (supports standard and Steam TOTP).
func GenTOTP(secret string, time time.Time, forSteam bool) string {
var totpToken string
@@ -83,8 +113,7 @@ func GenTOTP(secret string, time time.Time, forSteam bool) string {
}
if err != nil {
fmt.Println(AnsiError + "Error generating TOTP code" + AnsiReset)
os.Exit(ErrorOther)
PrintError("Error generating TOTP code", ErrorOther, true)
}
return totpToken
+11 -40
View File
@@ -3,55 +3,26 @@
package core
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard.
func copyField(executableName, copySubject string) {
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
cmd := exec.Command("pbcopy")
writeToStdin(cmd, copySubject)
WriteToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to copy to clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(ErrorClipboard)
}
Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds.
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
cmd := exec.Command("pbpaste")
newContents, err := cmd.Output()
if err != nil {
fmt.Println(AnsiError+"Failed to read clipboard contents:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
cmd = exec.Command("pbcopy")
writeToStdin(cmd, "")
err = cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to clear clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
}
os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
cmdClear := exec.Command("pbcopy")
WriteToStdin(cmdClear, "")
return exec.Command("pbpaste"), cmdClear
}
+12 -41
View File
@@ -1,57 +1,28 @@
//go:build linux && termux
//go:build android && termux
package core
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard.
func copyField(executableName, copySubject string) {
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
cmd := exec.Command("termux-clipboard-set")
writeToStdin(cmd, copySubject)
WriteToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to copy to clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(ErrorClipboard)
}
Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds.
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
cmd := exec.Command("termux-clipboard-get")
newContents, err := cmd.Output()
if err != nil {
fmt.Println(AnsiError+"Failed to read clipboard contents:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
cmd = exec.Command("termux-clipboard-set")
writeToStdin(cmd, "")
err = cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to clear clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
}
os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
cmdClear := exec.Command("termux-clipboard-set")
WriteToStdin(cmdClear, "")
return exec.Command("termux-clipboard-get"), cmdClear
}
+41
View File
@@ -0,0 +1,41 @@
//go:build !windows && !darwin && !android && !termux && !wsl
package core
import (
"os"
"os/exec"
)
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
var envSet, isWayland bool // track whether environment variables are set
var cmdCopy *exec.Cmd
// determine whether to use wl-copy (Wayland) or xclip (X11)
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
cmdCopy = exec.Command("wl-copy", "-t", "text/plain")
isWayland = true
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
cmdCopy = exec.Command("xclip", "-sel", "c", "-t", "text/plain")
} else {
PrintError("Clipboard platform could not be determined", ErrorClipboard, true)
}
WriteToStdin(cmdCopy, copySubject)
err := cmdCopy.Run()
if err != nil {
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
if !continuous {
LaunchClipClearProcess(copySubject, isWayland)
}
}
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
if os.Args[2] == "true" { // wayland
return exec.Command("wl-paste"), exec.Command("wl-copy", "-c")
}
return exec.Command("xclip", "-o", "-sel", "c"), exec.Command("xclip", "-i", "/dev/null", "-sel", "c")
}
-81
View File
@@ -1,81 +0,0 @@
//go:build !windows && !darwin && !termux && !wsl
package core
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard.
func copyField(executableName, copySubject string) {
var envSet bool // track whether environment variables are set
var cmd *exec.Cmd
// determine whether to use wl-copy (Wayland) or xclip (X11)
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
cmd = exec.Command("wl-copy")
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
cmd = exec.Command("xclip", "-sel", "c")
} else {
fmt.Println(AnsiError + "Clipboard platform could not be determined - Note that the clipboard does not function in a raw TTY" + AnsiReset)
os.Exit(ErrorClipboard)
}
writeToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to copy to clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(ErrorClipboard)
}
Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds.
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
// determine clipboard tool to use (wl-clipboard VS xclip)
var envSet bool
var cmdClear, cmdPaste *exec.Cmd
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
cmdClear = exec.Command("wl-copy", "-c")
cmdPaste = exec.Command("wl-paste")
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
cmdClear = exec.Command("xclip", "-i", "/dev/null", "-sel", "c")
cmdPaste = exec.Command("xclip", "-o", "-sel", "c")
} else {
fmt.Println(AnsiError + "Clipboard platform could not be determined - Neither $WAYLAND_DISPLAY nor $DISPLAY are set" + AnsiReset)
os.Exit(ErrorClipboard)
}
// read current clipboard contents
newContents, err := cmdPaste.Output()
if err != nil {
fmt.Println(AnsiError+"Failed to read clipboard contents:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
// clear clipboard if contents have not been modified
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
err = cmdClear.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to clear clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
}
os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
}
+8 -36
View File
@@ -4,52 +4,24 @@ package core
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard.
func copyField(executableName, copySubject string) {
// copyString copies a string to the clipboard.
func copyString(continuous bool, copySubject string) {
cmd := exec.Command("powershell.exe", "-c", fmt.Sprintf("echo '%s' | Set-Clipboard", strings.ReplaceAll(copySubject, "'", "''")))
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to copy to clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(ErrorClipboard)
}
Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
if !continuous {
LaunchClipClearProcess(copySubject)
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds.
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
cmd := exec.Command("powershell.exe", "-c", "Get-Clipboard")
newContents, err := cmd.Output()
if err != nil {
fmt.Println(AnsiError+"Failed to read clipboard contents:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
cmd = exec.Command("powershell.exe", "-c", "Set-Clipboard")
err = cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to clear clipboard:", err.Error()+AnsiReset)
os.Exit(ErrorClipboard)
}
}
os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
return exec.Command("powershell.exe", "-c", "Get-Clipboard"), exec.Command("powershell.exe", "-c", "Set-Clipboard")
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !returnOnExit
//go:build !interactive
package core
+2 -2
View File
@@ -1,8 +1,8 @@
//go:build returnOnExit
//go:build interactive
package core
// Exit (soft) is meant to be used in interactive implementations (GUIs/TUIs) to keep the program running after an operation.
func Exit(code int) {
func Exit(code int) int {
return code
}
+6 -10
View File
@@ -1,8 +1,6 @@
package core
import (
"fmt"
"os"
"os/exec"
"strings"
)
@@ -18,22 +16,20 @@ func DecryptGPG(targetLocation string) []string {
enableVirtualTerminalProcessing()
if err != nil {
fmt.Println(AnsiError + "Failed to decrypt \"" + targetLocation + "\" - Ensure it is a valid GPG-encrypted file and that you entered your passphrase correctly" + AnsiReset)
os.Exit(ErrorDecryption)
PrintError("Failed to decrypt \""+targetLocation+"\" - Ensure it is a valid GPG-encrypted file and that you entered your passphrase correctly", ErrorDecryption, true)
}
outputSlice := strings.Split(string(output), "\n")
return outputSlice
return strings.Split(string(output), "\n")
}
// EncryptGPG encrypts a slice of strings using GPG and returns the encrypted data as a byte slice.
func EncryptGPG(input []string) []byte {
cmd := exec.Command("gpg", "-q", "-r", ParseConfig([][2]string{{"LIBMUTTON", "gpgID"}}, "")[0], "-e")
writeToStdin(cmd, strings.Join(input, "\n"))
gpgCfg, _ := ParseConfig([][2]string{{"LIBMUTTON", "gpgID"}}, "")
cmd := exec.Command("gpg", "-q", "-r", gpgCfg[0], "-e")
WriteToStdin(cmd, strings.Join(input, "\n"))
encryptedBytes, err := cmd.Output()
if err != nil {
fmt.Println(AnsiError + "Failed to encrypt data - Ensure that your GPG key is valid and that you have a valid GPG ID set in libmutton.ini" + AnsiReset)
os.Exit(ErrorEncryption)
PrintError("Failed to encrypt data - Ensure that you have a valid GPG ID set in libmutton.ini", ErrorEncryption, true)
}
return encryptedBytes
}
+4 -9
View File
@@ -1,7 +1,6 @@
package core
import (
"fmt"
"os"
"os/exec"
"strconv"
@@ -45,8 +44,7 @@ func GpgKeyGen() string {
cmd.Stdin = os.Stdin
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError+"Failed to generate GPG key:", err.Error()+AnsiReset)
os.Exit(ErrorOther)
PrintError("Failed to generate GPG key: "+err.Error(), ErrorOther, true)
}
return "libmutton-" + unixTime + " (gpg-libmutton) <github.com/rwinkhart/libmutton>"
@@ -58,8 +56,7 @@ func DirInit(preserveOldConfigDir bool) string {
// create EntryRoot
err := os.MkdirAll(EntryRoot, 0700)
if err != nil {
fmt.Println(AnsiError+"Failed to create \""+EntryRoot+"\":", err.Error()+AnsiReset)
os.Exit(ErrorWrite)
PrintError("Failed to create \""+EntryRoot+"\": "+err.Error(), ErrorWrite, true)
}
// get old device ID before its potential removal
@@ -77,8 +74,7 @@ func DirInit(preserveOldConfigDir bool) string {
if isAccessible {
err = os.RemoveAll(ConfigDir)
if err != nil {
fmt.Println(AnsiError+"Failed to remove existing config directory:", err.Error()+AnsiReset)
os.Exit(ErrorWrite)
PrintError("Failed to remove existing config directory: "+err.Error(), ErrorWrite, true)
}
}
}
@@ -86,8 +82,7 @@ func DirInit(preserveOldConfigDir bool) string {
// create config directory w/devices subdirectory
err = os.MkdirAll(ConfigDir+PathSeparator+"devices", 0700)
if err != nil {
fmt.Println(AnsiError+"Failed to create \""+ConfigDir+"\":", err.Error()+AnsiReset)
os.Exit(ErrorWrite)
PrintError("Failed to create \""+ConfigDir+"\": "+err.Error(), ErrorWrite, true)
}
return oldDeviceID
+21
View File
@@ -0,0 +1,21 @@
//go:build (windows || darwin || android || termux || wsl) && !interactive
package core
import (
"os"
"os/exec"
)
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For non-interactive CLI implementations, an entirely separate process is created for this purpose.
func LaunchClipClearProcess(copySubject string) {
executableName := os.Args[0]
cmd := exec.Command(executableName, "clipclear")
WriteToStdin(cmd, copySubject)
err := cmd.Start()
if err != nil {
PrintError("Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?", ErrorClipboard, true)
}
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !windows && !darwin && !android && !termux && !wsl && !interactive
package core
import (
"os"
"os/exec"
"strconv"
)
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For non-interactive CLI implementations, an entirely separate process is created for this purpose.
func LaunchClipClearProcess(copySubject string, isWayland bool) {
executableName := os.Args[0]
cmd := exec.Command(executableName, "clipclear", strconv.FormatBool(isWayland))
WriteToStdin(cmd, copySubject)
err := cmd.Start()
if err != nil {
PrintError("Failed to launch automated clipboard clearing process - Does this libmutton implementation support the \"clipclear\" argument?", ErrorClipboard, true)
}
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
}
+10
View File
@@ -0,0 +1,10 @@
//go:build (windows || darwin || android || termux || wsl) && interactive
package core
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
func LaunchClipClearProcess(copySubject string) {
go clipClearProcess(copySubject)
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows && !darwin && !android && !termux && !wsl && interactive
package core
import (
"os"
"strconv"
)
// LaunchClipClearProcess launches the timed clipboard clearing process.
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
func LaunchClipClearProcess(copySubject string, isWayland bool) {
os.Args = []string{os.Args[0], "", strconv.FormatBool(isWayland)}
go clipClearProcess(copySubject)
}
+59 -24
View File
@@ -18,50 +18,38 @@ func TargetIsFile(targetLocation string, errorOnFail bool, failCondition uint8)
targetInfo, err := os.Stat(targetLocation)
if err != nil {
if errorOnFail {
fmt.Println(AnsiError + "Failed to access \"" + targetLocation + "\" - Ensure it exists and has the correct permissions" + AnsiReset)
os.Exit(ErrorTargetNotFound)
PrintError("Failed to access \""+targetLocation+"\" - Ensure it exists and has the correct permissions", ErrorTargetNotFound, true)
}
return false, false
}
if targetInfo.IsDir() {
if errorOnFail && failCondition == 2 {
fmt.Println(AnsiError + "\"" + targetLocation + "\" is a directory" + AnsiReset)
os.Exit(ErrorTargetWrongType)
PrintError("\""+targetLocation+"\" is a directory", ErrorTargetWrongType, true)
}
return false, true
} else {
if errorOnFail && failCondition == 1 {
fmt.Println(AnsiError + "\"" + targetLocation + "\" is a file" + AnsiReset)
os.Exit(ErrorTargetWrongType)
PrintError("\""+targetLocation+"\" is a file", ErrorTargetWrongType, true)
}
return true, true
}
}
// WriteEntry writes entryData to an encrypted file at targetLocation.
func WriteEntry(targetLocation string, entryData []string, verifyEntryDoesNotExist bool) {
if verifyEntryDoesNotExist {
_, isAccessible := TargetIsFile(targetLocation, false, 0)
if isAccessible {
fmt.Println(AnsiError + "Target location already exists" + AnsiReset)
os.Exit(ErrorTargetExists)
}
}
func WriteEntry(targetLocation string, entryData []string) {
encryptedBytes := EncryptGPG(entryData)
err := os.WriteFile(targetLocation, encryptedBytes, 0600)
if err != nil {
fmt.Println(AnsiError+"Failed to write to file:", err.Error()+AnsiReset)
os.Exit(ErrorWrite)
PrintError("Failed to write to file: "+err.Error(), ErrorWrite, true)
}
}
// writeToStdin is a utility function that writes a string to a command's stdin.
func writeToStdin(cmd *exec.Cmd, input string) {
// WriteToStdin is a utility function that writes a string to a command's stdin.
// TODO unexport (import?) after migration off of GPG
func WriteToStdin(cmd *exec.Cmd, input string) {
stdin, err := cmd.StdinPipe()
if err != nil {
fmt.Println(AnsiError+"Failed to access stdin for system command:", err.Error()+AnsiReset)
os.Exit(ErrorOther)
PrintError("Failed to access stdin for system command: "+err.Error(), ErrorOther, true)
}
go func() {
@@ -76,8 +64,7 @@ func writeToStdin(cmd *exec.Cmd, input string) {
func CreateTempFile() *os.File {
tempFile, err := os.CreateTemp("", "*.markdown")
if err != nil {
fmt.Println(AnsiError+"Failed to create temporary file:", err.Error()+AnsiReset)
os.Exit(ErrorWrite)
PrintError("Failed to create temporary file: "+err.Error(), ErrorWrite, true)
}
return tempFile
}
@@ -89,7 +76,37 @@ func RemoveTrailingEmptyStrings(slice []string) []string {
return slice[:i+1]
}
}
return []string{}
return nil
}
// ClampTrailingWhitespace strips trailing newlines, carriage returns, and tabs from each line in a note.
// Additionally, it removes single trailing spaces and truncates multiple trailing spaces to two (for Markdown formatting).
func ClampTrailingWhitespace(note []string) {
for i, line := range note {
// remove trailing tabs, carriage returns, and newlines
note[i] = strings.TrimRight(line, "\t\r\n")
// determine the number of trailing spaces
var endSpacesCount int
for j := len(line) - 1; j >= 0; j-- {
if line[j] != ' ' {
break
}
endSpacesCount++
}
// remove single spaces, truncate multiple spaces (leave two for Markdown formatting)
switch endSpacesCount {
case 0:
// do nothing
case 1:
// remove the trailing space
note[i] = strings.TrimRight(line, " ")
default:
// truncate the trailing spaces to two
note[i] = line[:len(line)-endSpacesCount+2]
}
}
}
// StringGen generates a random string of a specified length and complexity.
@@ -154,3 +171,21 @@ func EntryIsNotEmpty(entryData []string) bool {
}
return false
}
// ExpandPathWithHome, given a path (as a string) containing "~", returns the path with "~" expanded to the user's home directory.
func ExpandPathWithHome(path string) string {
return strings.Replace(path, "~", Home, 1)
}
// PrintError prints an error message in the standard libmutton format and exits with the specified exit code.
// Requires: message (the error message to print),
// exitCode (the exit code to use),
// forceHardExit (if true, exit immediately; if false, allow soft exit for interactive clients).
func PrintError(message string, exitCode int, forceHardExit bool) {
fmt.Println(AnsiError + message + AnsiReset)
if forceHardExit {
os.Exit(exitCode)
} else {
Exit(exitCode)
}
}
+35
View File
@@ -1,3 +1,38 @@
**libmutton v0.2.4**
December 13, 2024
## IMPORTANT
- This release includes a dependency bump to address a high-severity CVE (CVE-2024-45337) - Please update as soon as possible!
## Changes
- (91fdeb53d5b58fe0a554ef29c1384a50b1a9ec33) The MIME type of data copied to the clipboard on UNIX-like systems (X11 and Wayland) is now statically set to "text/plain" to avoid potential issues with type inference
## Dependencies
- Bumps (direct and indirect)
- Go: v1.23.2 => v1.23.4
- github.com/pkg/sftp: v1.13.6 => v1.13.7
- golang.org/x/crypto: v0.28.0 => v0.31.0
- Addresses CVE-2024-45337
- golang.org/x/sys: v0.26.0 => v0.28.0
- golang.org/x/term: v0.25.0 => v0.27.0
---
**libmutton v0.2.3**
October 19, 2024
## Fixes
- (1fb283484469192fc7c954f14d6f2be5234d7c6b) Fixed renaming/moving an entry/folder to an invalid path still having clients request the operation on the server
## Dependencies
- Bumps (direct and indirect)
- Go: v1.22.6 => v1.23.2
- golang.org/x/crypto: v0.26.0 => v0.28.0
- golang.org/x/sys: v0.24.0 => v0.26.0
- golang.org/x/term: v0.23.0 => v0.25.0
---
**libmutton v0.2.2**
August 20, 2024
+35
View File
@@ -0,0 +1,35 @@
**libmutton v0.3.0**
February 22, 2025
## Features
- (f9aa2fc374b77fc8325d0c7644ffc9c96169c3c6) (d2034b458b702b8f1664bc188c1840040ec0f704) (81b78068a75a23c73be607cbe6d4dc8b1539f831) `core.LaunchClipClearProcess`, `core.WriteToStdin`, `core.ExpandPathWithHome`, and `core.PrintError` are now exported utility functions for direct use by clients
- (c28d45c9da94ec45d89dbda6d645042a7fdbcd29) One-off sync functions can now be forced to run in offline mode
- (5028fb21b019e8a867d031463f5de1402fc053f4) SSH connection attempts now have a 3-second timeout
- (89b74cec1e014dba31e2b728e3544941d6edd2c2) Individual keys can now be removed from the config file
- Many features for building interactive (GUI/TUI) clients
- (619e4220a60fc0cb284f5640eaf9af86c0651ac7) Clipboard clearing can now be handled much more sensibly by interactive clients
- (3b57d59ddcf0694b2bbae8c86ec06faff299e950) (55be8a30759ee5d12628c631af94a57d582281a4) The clipboard can now be cleared instantly (no timer); useful for clearing on client exit
- (2feeeb74d7813dbbb75795d5ccdd817bbcea3602) `core.ParseConfig` now returns errors for proper handling in interactive clients
- (68e3108741fae0aaf316dcf33a5074b0e19a7b4f) `sync.RunJob` can now return lists of synchronized entries for display in interactive clients
## Fixes
- (eb2b349697ede136ea031cf7d82bfb72a5a0dcf9) Deletions are now synchronized before folders to avoid sync failures under unlikely conditions
- (fc1cd349b8c5468afa3feaf7b5b9042eb4c467e7) Double-space line breaks with Markdown formatting are now preserved when saving an entry
- (4689cae3f4a1e19486935f2da5b06bf0a90c731c) Sheared folder names now always end in a trailing slash for clarity and reduced duplicate entries in the deletions directory
- Many fixes for building interactive (GUI/TUI) clients
- (1cebb395460fb6068ebf4253ec8bd93e9d2b2a96) The soft exit function now specifies a return value
- (a4a39a3a995d1560514dc3b782abb26329214c3f) Passphrase-protected SSH identity files are no longer broken for interactive clients
- (1c650d475145c3db4e370576d89eb77abe6e5e52) (0162b737c84d42e168803e0c074103b5c0676384) Interactive clients will no longer crash on SSH dialing failures
- (173574eb85b3172414d104a2bf391a3bce4ddc95) Error messages not making sense for interactive clients have been addressed
## Optimizations
- (f2f9c523f441b49d682795c01454167d68aae573) An unnecessary variable declaration was removed in `core.DecryptGPG`
- (fb5449cf1f75aa0f93c0484d590abca04f977d7a) A redundant (and late) check for the pre-existence of a new entry has been removed
- (e9d0c3704362c8963e63a6c3b70d036f3e6c2493) `core.PrintError` has been used to decrease the overall binary size through improved code re-use
## Dependencies
- Bumps (direct and indirect)
- Go: v1.23.4 => v1.24.0
- golang.org/x/crypto: v0.31.0 => v0.34.0
- golang.org/x/sys: v0.28.0 => v0.30.0
+4 -5
View File
@@ -1,18 +1,17 @@
module github.com/rwinkhart/libmutton
go 1.22.6
go 1.24.0
require (
github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727
github.com/pkg/sftp v1.13.6
github.com/pkg/sftp v1.13.7
github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481
golang.org/x/crypto v0.26.0
golang.org/x/term v0.23.0
golang.org/x/crypto v0.34.0
gopkg.in/ini.v1 v1.67.0
)
require (
github.com/boombuler/barcode v1.0.2 // indirect
github.com/kr/fs v0.1.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)
+23 -13
View File
@@ -8,8 +8,8 @@ github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727 h1:1RkPJqfzrn
github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727/go.mod h1:wRAWHbTlpt0C4kwnKoa42L2Phrv6uIq+c50P2uKpb7I=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM=
github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481 h1:FkxbO331O7mS5EJkP+MCi0o2gswh/Aezs+//NmefrR8=
@@ -23,36 +23,46 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
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.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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+2 -4
View File
@@ -77,7 +77,7 @@ func main() {
}
func helpServer() {
fmt.Print(ansiBold + "\nlibmuttonserver | Copyright (c) 2024 Randall Winkhart\n" + core.AnsiReset + `
fmt.Print(ansiBold + "\nlibmuttonserver | Copyright (c) 2024-2025 Randall Winkhart\n" + core.AnsiReset + `
This software exists under the MIT license; you may redistribute it under certain conditions.
This program comes with absolutely no warranty; type "libmuttonserver version" for details.
@@ -116,7 +116,5 @@ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.` + "\n\n---------------------------------------------------------")
fmt.Print(ansiBold + "\n\n libmuttonserver" + core.AnsiReset + " Version " + core.LibmuttonVersion + `
Copyright (c) 2024 Randall Winkhart` + "\n\n")
fmt.Print(ansiBold + "\n\n libmuttonserver" + core.AnsiReset + " Version " + core.LibmuttonVersion + "\n\n Copyright (c) 2024-2025: Randall Winkhart" + "\n\n")
}
+55 -65
View File
@@ -27,11 +27,11 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
var sshUserConfig []string
var missingValueError string
if manualSync {
missingValueError = joinErrorWithEXE("SSH settings not configured - Run \"", " init\" to configure")
missingValueError = "SSH settings not fully configured"
} else {
missingValueError = "0"
missingValueError = "0" // allow silent exit at this point in offline mode
}
sshUserConfig = core.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}, missingValueError)
sshUserConfig, _ = core.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}, missingValueError)
var user, ip, port, keyFile, keyFileProtected, entryRoot string
var isWindows bool
@@ -53,8 +53,7 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
case 6:
isWindows, err = strconv.ParseBool(key)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to parse server OS type:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to parse server OS type: "+err.Error(), core.ErrorRead, true)
}
}
}
@@ -62,8 +61,7 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
// read private key
key, err := os.ReadFile(keyFile)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to read private key file:", keyFile+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to read private key: "+keyFile, core.ErrorRead, true)
}
// parse private key
@@ -71,19 +69,17 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
if keyFileProtected != "true" {
parsedKey, err = ssh.ParsePrivateKey(key)
} else {
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, inputKeyFilePassphrase())
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, core.PassphraseInputFunction("Enter passphrase for your SSH keyfile:"))
}
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to parse private key:", keyFile+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to parse private key: "+keyFile, core.ErrorRead, true)
}
// read known hosts file
var hostKeyCallback ssh.HostKeyCallback
hostKeyCallback, err = knownhosts.New(core.Home + core.PathSeparator + ".ssh" + core.PathSeparator + "known_hosts")
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to read known hosts file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to read known hosts file: "+err.Error(), core.ErrorRead, true)
}
// configure SSH client
@@ -93,13 +89,14 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
ssh.PublicKeys(parsedKey),
},
HostKeyCallback: hostKeyCallback,
Timeout: 3 * time.Second,
}
// connect to SSH server
sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to connect to remote server:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to connect to remote server: "+err.Error(), core.ErrorServerConnection, false) // do not crash/close interactive clients
return nil, "", false
}
return sshClient, entryRoot, isWindows
@@ -110,8 +107,7 @@ func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
// create a session
sshSession, err := sshClient.NewSession()
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to establish SSH session:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to establish SSH session: "+err.Error(), core.ErrorServerConnection, true)
}
// provide stdin data for session
@@ -121,8 +117,7 @@ func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
var output []byte
output, err = sshSession.CombinedOutput(cmd)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to run SSH command:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorSyncProcess)
core.PrintError("Sync failed - Unable to run SSH command: "+err.Error(), core.ErrorSyncProcess, true)
}
// convert the output to a string and remove leading/trailing whitespace
@@ -138,8 +133,7 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
deviceIDList := core.GenDeviceIDList(true)
if len(*deviceIDList) == 0 {
if manualSync {
fmt.Println(joinErrorWithEXE("Sync failed - No device ID found; run \"", " init\" to generate a device ID"))
os.Exit(core.ErrorTargetNotFound)
core.PrintError("Sync failed - No device ID found", core.ErrorTargetNotFound, true)
} else {
core.Exit(0) // exit silently if the sync job was called automatically, as the user may just be in offline mode
}
@@ -152,13 +146,11 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
// parse output/re-form lists
if len(outputSlice) != 5 { // ensure information from server is complete
fmt.Println(core.AnsiError + "Sync failed - Unable to fetch remote data; server returned an unexpected response" + core.AnsiReset)
os.Exit(core.ErrorSyncProcess)
core.PrintError("Sync failed - Unable to fetch remote data; server returned an unexpected response", core.ErrorSyncProcess, true)
}
serverTime, err := strconv.ParseInt(outputSlice[0], 10, 64)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to parse server time:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to parse server time: "+err.Error(), core.ErrorRead, true)
}
entries := strings.Split(outputSlice[1], core.FSMisc)[1:]
modsStrings := strings.Split(outputSlice[2], core.FSMisc)[1:]
@@ -171,8 +163,7 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
for _, modString := range modsStrings {
mod, err = strconv.ParseInt(modString, 10, 64)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to parse mod time:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to parse mod time: "+err.Error(), core.ErrorRead, true)
}
mods = append(mods, mod)
}
@@ -218,14 +209,12 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
// create an SFTP client from sshClient
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to establish SFTP session:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to establish SFTP session: "+err.Error(), core.ErrorServerConnection, true)
}
defer func(sftpClient *sftp.Client) {
err = sftpClient.Close()
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to close SFTP client:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to close SFTP client: "+err.Error(), core.ErrorServerConnection, true)
}
}(sftpClient)
@@ -243,8 +232,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
var fileInfo os.FileInfo
fileInfo, err = sftpClient.Stat(remoteEntryFullPath)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to get remote file info (mod time):", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to get remote file info (mod time): "+err.Error(), core.ErrorRead, true)
}
modTime := fileInfo.ModTime()
@@ -252,8 +240,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
var remoteFile *sftp.File
remoteFile, err = sftpClient.Open(remoteEntryFullPath)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to open remote file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to open remote file: "+err.Error(), core.ErrorRead, true)
}
// store path to local entry
@@ -263,15 +250,13 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
var localFile *os.File
localFile, err = os.OpenFile(localEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to create local file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Sync failed - Unable to create local file: "+err.Error(), core.ErrorWrite, true)
}
// download the file
_, err = remoteFile.WriteTo(localFile)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to download remote file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorSyncProcess)
core.PrintError("Sync failed - Unable to download remote file: "+err.Error(), core.ErrorSyncProcess, true)
}
// close the files
@@ -300,8 +285,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
var fileInfo os.FileInfo
fileInfo, err = os.Stat(localEntryFullPath)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to get local file info (mod time):", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to get local file info (mod time): "+err.Error(), core.ErrorRead, true)
}
modTime := fileInfo.ModTime()
@@ -309,8 +293,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
var localFile *os.File
localFile, err = os.Open(localEntryFullPath)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to open local file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Sync failed - Unable to open local file: "+err.Error(), core.ErrorRead, true)
}
// store path to remote entry
@@ -320,15 +303,13 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
var remoteFile *sftp.File
remoteFile, err = sftpClient.OpenFile(remoteEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to create remote file ("+remoteEntryFullPath+"):", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Sync failed - Unable to create remote file ("+remoteEntryFullPath+"): "+err.Error(), core.ErrorWrite, true)
}
// upload the file
_, err = localFile.WriteTo(remoteFile)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to upload local file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorSyncProcess)
core.PrintError("Sync failed - Unable to upload local file: "+err.Error(), core.ErrorSyncProcess, true)
}
// close the files
@@ -338,8 +319,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
// set permissions on remote file
err = sftpClient.Chmod(remoteEntryFullPath, 0600)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to set permissions on remote file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorSyncProcess)
core.PrintError("Sync failed - Unable to set permissions on remote file: "+err.Error(), core.ErrorSyncProcess, true)
}
// set the modification time of the remote file to match the value saved from the local file (from before the upload)
@@ -353,7 +333,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
// syncLists determines which entries need to be downloaded and uploaded for synchronizations and calls sftpSync with this information.
// Using maps means that syncing will be done in an arbitrary order, but it is a worthy tradeoff for speed and simplicity.
func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSynced bool, localEntryModMap, remoteEntryModMap map[string]int64) {
func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSynced, returnLists bool, localEntryModMap, remoteEntryModMap map[string]int64) [3][]string {
// initialize slices to store entries that need to be downloaded or uploaded
var downloadList, uploadList []string
@@ -393,6 +373,11 @@ func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSyn
}
fmt.Println("Client is synchronized with server")
if returnLists {
return [3][]string{nil, downloadList, uploadList}
}
return [3][]string{nil, nil, nil}
}
// deletionSync removes entries from the client that have been deleted on the server (multi-client deletion).
@@ -403,8 +388,7 @@ func deletionSync(deletions []string) {
fmt.Println(ansiDelete+deletion+core.AnsiReset, "has been sheared, removing locally (if it exists)")
err := os.RemoveAll(core.TargetLocationFormat(deletion))
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Failed to shear "+deletion+" locally:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Sync failed - Failed to shear "+deletion+" locally: "+err.Error(), core.ErrorWrite, true)
}
}
@@ -425,38 +409,39 @@ func folderSync(folders []string) {
if !isFile && !isAccessible {
err := os.MkdirAll(folderFullPath, 0700)
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Failed to create folder \""+folder+"\":", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Sync failed - Failed to create folder ("+folder+"): "+err.Error(), core.ErrorWrite, true)
}
} else if isFile {
fmt.Println(core.AnsiError + "Sync failed - Failed to create folder \"" + folder + "\" - A file with the same name already exists" + core.AnsiReset)
os.Exit(core.ErrorTargetExists)
core.PrintError("Sync failed - Failed to create folder ("+folder+") - A file with the same name already exists", core.ErrorTargetExists, true)
}
}
}
// RunJob runs the SSH sync job.
// Setting manualSync to true will throw errors if sync is not configured (online mode is assumed).
func RunJob(manualSync bool) {
// Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client.
func RunJob(manualSync, returnLists bool) [3][]string {
// get SSH client to re-use throughout the sync process
sshClient, sshEntryRoot, sshIsWindows := GetSSHClient(manualSync)
if sshClient == nil { // indicate SSH dialing failure for interactive clients
return [3][]string{nil, nil, nil}
}
defer func(sshClient *ssh.Client) {
err := sshClient.Close()
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to close SSH client:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}(sshClient)
// fetch remote lists
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime := getRemoteDataFromClient(sshClient, manualSync)
// sync folders
folderSync(remoteFolders)
// sync deletions
deletionSync(deletions)
// sync folders
folderSync(remoteFolders)
// fetch local lists
localEntryModMap := getLocalData()
@@ -469,8 +454,13 @@ func RunJob(manualSync bool) {
}
// sync new and updated entries
syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, localEntryModMap, remoteEntryModMap)
// exit program after successful sync
core.Exit(0)
var lists [3][]string
if returnLists {
lists = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap)
lists[0] = deletions
return lists
}
syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap)
core.Exit(0) // exit program if running non-interactively
return lists // dummy return for when not returning lists
}
+12 -15
View File
@@ -1,7 +1,6 @@
package sync
import (
"fmt"
"os"
"strings"
@@ -20,10 +19,11 @@ func getModTimes(entryList []string) []int64 {
}
// ShearLocal removes the target file or directory from the local system.
// Returns: deviceID (only on client; for use in ShearRemoteFromClient).
// Returns: deviceID (only on client; for use in ShearRemoteFromClient),
// isDir (only on client; for use in ShearRemoteFromClient).
// If the local system is a server, it will also add the target to the deletions list for all clients (except the requesting client).
// This function should only be used directly by the server binary.
func ShearLocal(targetLocationIncomplete, clientDeviceID string) string {
func ShearLocal(targetLocationIncomplete, clientDeviceID string) (string, bool) {
// determine if running on a server
var onServer bool
if clientDeviceID != "" {
@@ -49,19 +49,19 @@ func ShearLocal(targetLocationIncomplete, clientDeviceID string) string {
// get the full targetLocation path and remove the target
targetLocationComplete := core.TargetLocationFormat(targetLocationIncomplete)
var isFile bool
if !onServer { // error if target does not exist on client, needed because os.RemoveAll does not return an error if target does not exist
core.TargetIsFile(targetLocationComplete, true, 0)
isFile, _ = core.TargetIsFile(targetLocationComplete, true, 0)
}
err := os.RemoveAll(targetLocationComplete)
if err != nil {
fmt.Println(core.AnsiError+"Failed to remove local target:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Failed to remove local target: "+err.Error(), core.ErrorWrite, true)
}
if !onServer && len(*deviceIDList) > 0 { // return the device ID if running on the client and a device ID exists (online mode)
return (*deviceIDList)[0].Name()
return (*deviceIDList)[0].Name(), !isFile
}
return ""
return "", true
// do not exit program, as this function is used as part of ShearRemoteFromClient
}
@@ -80,14 +80,13 @@ func RenameLocal(oldLocationIncomplete, newLocationIncomplete string, verifyOldL
// ensure newLocation does not exist
_, isAccessible := core.TargetIsFile(newLocation, false, 0)
if isAccessible {
fmt.Println(core.AnsiError + "\"" + newLocation + "\" already exists" + core.AnsiReset)
os.Exit(core.ErrorTargetExists)
core.PrintError("\""+newLocation+"\" already exists", core.ErrorTargetExists, true)
}
// rename oldLocation to newLocation
err := os.Rename(oldLocation, newLocation)
if err != nil {
fmt.Println(core.AnsiError + "Failed to rename - Does the target containing directory exist?" + core.AnsiReset)
core.PrintError("Failed to rename - Does the target containing directory exist?", core.ErrorTargetNotFound, true)
}
// do not exit program, as this function is used as part of RenameRemoteFromClient
@@ -101,11 +100,9 @@ func AddFolderLocal(targetLocationIncomplete string) {
err := os.Mkdir(targetLocationComplete, 0700)
if err != nil {
if os.IsExist(err) {
fmt.Println(core.AnsiError + "Directory already exists" + core.AnsiReset)
os.Exit(core.ErrorTargetExists)
core.PrintError("Directory already exists", core.ErrorTargetExists, true)
} else {
fmt.Println(core.AnsiError+"Failed to create directory:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Failed to create directory: "+err.Error(), core.ErrorWrite, true)
}
}
+2 -10
View File
@@ -3,7 +3,6 @@
package sync
import (
"fmt"
"io/fs"
"os"
"path/filepath"
@@ -25,12 +24,10 @@ func WalkEntryDir() ([]string, []string) {
// check for errors encountered while walking directory
if err != nil {
if os.IsNotExist(err) {
fmt.Println(core.AnsiError+"The entry directory does not exist - Run \""+os.Args[0], "init"+"\" to create it"+core.AnsiReset)
core.PrintError("The entry directory does not exist - Initialize libmutton to create it", core.ErrorOther, true)
} else {
// otherwise, print the source of the error
fmt.Println(core.AnsiError+"An unexpected error occurred while generating the entry list:", err.Error()+core.AnsiReset)
core.PrintError("An unexpected error occurred while generating the entry list: "+err.Error(), core.ErrorOther, true)
}
os.Exit(core.ErrorOther)
}
// trim root path from each path before storing
@@ -48,8 +45,3 @@ func WalkEntryDir() ([]string, []string) {
return fileList, dirList
}
// joinErrorWithEXE is a utility function that joins and returns the two strings it is provided (in error format) with the executable name inserted between them.
func joinErrorWithEXE(firstHalf, secondHalf string) string {
return core.AnsiError + firstHalf + os.Args[0] + secondHalf + core.AnsiReset
}
+2 -10
View File
@@ -3,7 +3,6 @@
package sync
import (
"fmt"
"io/fs"
"os"
"path/filepath"
@@ -26,12 +25,10 @@ func WalkEntryDir() ([]string, []string) {
// check for errors encountered while walking directory
if err != nil {
if os.IsNotExist(err) {
fmt.Println(joinErrorWithEXE("The entry directory does not exist - Run \"", " init"+"\" to create it"))
core.PrintError("The entry directory does not exist - Initialize libmutton to create it", core.ErrorOther, true)
} else {
// otherwise, print the source of the error
fmt.Println(core.AnsiError+"An unexpected error occurred while generating the entry list:", err.Error()+core.AnsiReset)
core.PrintError("An unexpected error occurred while generating the entry list: "+err.Error(), core.ErrorOther, true)
}
os.Exit(core.ErrorOther)
}
// trim root path from each path before storing and replace backslashes with forward slashes
@@ -49,8 +46,3 @@ func WalkEntryDir() ([]string, []string) {
return fileList, dirList
}
// joinErrorWithEXE is a utility function that joins and returns the two strings it is provided (in error format) with the executable name inserted between them.
func joinErrorWithEXE(firstHalf, secondHalf string) string {
return core.AnsiError + firstHalf + os.Args[0][strings.LastIndex(os.Args[0], "\\")+1:] + secondHalf + core.AnsiReset
}
+3 -7
View File
@@ -1,7 +1,6 @@
package sync
import (
"fmt"
"math/rand"
"os"
"strconv"
@@ -24,8 +23,7 @@ func DeviceIDGen(oldDeviceID string) (string, string) {
// create new device ID file (locally)
fileToClose, err := os.OpenFile(core.ConfigDir+core.PathSeparator+"devices"+core.PathSeparator+newDeviceID, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
fmt.Println(core.AnsiError+"Failed to create local device ID file:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Failed to create local device ID file: "+err.Error(), core.ErrorWrite, true)
}
_ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed
@@ -36,15 +34,13 @@ func DeviceIDGen(oldDeviceID string) (string, string) {
sshEntryRootSSHIsWindows := strings.Split(GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID), core.FSSpace)
err = sshClient.Close()
if err != nil {
fmt.Println(core.AnsiError+"Init failed - Unable to close SSH client:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Init failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
// remove old device ID file (locally; may not exist)
err = os.RemoveAll(core.ConfigDir + core.PathSeparator + "devices" + core.PathSeparator + oldDeviceID)
if err != nil {
fmt.Println(core.AnsiError+"Failed to remove old device ID file (locally):", err.Error()+core.AnsiReset)
os.Exit(core.ErrorWrite)
core.PrintError("Failed to remove old device ID file (locally): "+err.Error(), core.ErrorWrite, true)
}
return sshEntryRootSSHIsWindows[0], sshEntryRootSSHIsWindows[1]
-17
View File
@@ -1,17 +0,0 @@
package sync
import (
"fmt"
"os"
"golang.org/x/term"
)
// inputKeyFilePassphrase prompts the user for a passphrase for an SSH key file.
// TODO support non-CLI implementations
func inputKeyFilePassphrase() []byte {
fmt.Print("\nEnter passphrase for your SSH keyfile: ")
passphrase, _ := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
return passphrase
}
+15 -15
View File
@@ -1,8 +1,6 @@
package sync
import (
"fmt"
"os"
"strings"
"github.com/rwinkhart/libmutton/core"
@@ -10,21 +8,25 @@ 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) {
deviceID := ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
deviceID, isDir := ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
if deviceID != "" { // ensure a device ID exists (online mode)
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, _, _ := GetSSHClient(false)
// ensure targetLocationIncomplete ends with a slash if it is a directory (for clarity in shear message)
if isDir && !strings.HasSuffix(targetLocationIncomplete, "/") {
targetLocationIncomplete += "/"
}
// call the server to remotely shear the target and add it to the deletions list
GetSSHOutput(sshClient, "libmuttonserver shear", deviceID+"\n"+strings.ReplaceAll(targetLocationIncomplete, core.PathSeparator, core.FSPath))
// close the SSH client
err := sshClient.Close()
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to close SSH client:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}
@@ -33,11 +35,11 @@ func ShearRemoteFromClient(targetLocationIncomplete string) {
// 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) {
func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, forceOffline bool) {
RenameLocal(oldLocationIncomplete, newLocationIncomplete, false) // move the target on the local system
deviceIDList := core.GenDeviceIDList(true)
if len(*deviceIDList) > 0 { // ensure a device ID exists (online mode)
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, _, _ := GetSSHClient(false)
@@ -50,8 +52,7 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string)
// close the SSH client
err := sshClient.Close()
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to close SSH client:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}
@@ -60,11 +61,11 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string)
// 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) {
func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
AddFolderLocal(targetLocationIncomplete) // add the folder on the local system
deviceIDList := core.GenDeviceIDList(true)
if len(*deviceIDList) > 0 { // ensure a device ID exists (online mode)
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, _, _ := GetSSHClient(false)
@@ -74,8 +75,7 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string) {
// close the SSH client
err := sshClient.Close()
if err != nil {
fmt.Println(core.AnsiError+"Sync failed - Unable to close SSH client:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorServerConnection)
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
}
}
+1 -2
View File
@@ -17,8 +17,7 @@ func GetRemoteDataFromServer(clientDeviceID string) {
modList := getModTimes(entryList)
deletionsList, err := os.ReadDir(core.ConfigDir + core.PathSeparator + "deletions")
if err != nil {
fmt.Println(core.AnsiError+"Failed to read the deletions directory:", err.Error()+core.AnsiReset)
os.Exit(core.ErrorRead)
core.PrintError("Failed to read the deletions directory: "+err.Error(), core.ErrorRead, true)
}
// print the current UNIX timestamp to stdout
-1
View File
@@ -1,4 +1,3 @@
## Known Bugs - libmutton
- On Windows, GPG is sometimes (seems unpredictable) incredibly slow to start (often after a reboot), leading to many operations seemingly hanging
- **This will be addressed** in the migration off of GPG that will take place before v1.0.0
- Password-protected SSH identity files currently only prompt for password entry in the CLI, and thus they are not yet supported in GUI/TUI implementations
+7 -6
View File
@@ -11,14 +11,15 @@ If any functionality in these two packages proves to be difficult to implement i
Custom build tags can (and sometimes must) be used to achieve desired results.
These are as follows:
- `returnOnExit`: If making an interactive interface (GUI/TUI/interactive CLI), you probably need to use this build tag. Without it, your entire program will exit after any given operation is completed. This behavior is only desired for non-interactive CLI implementations, such as MUTN. Currently, errors will result in the program exiting **even with this build tag**. This may be changed in the future (under evaluation).
- `interactive`: If making an interactive interface (GUI/TUI/interactive CLI), you probably need to use this build tag. Without it, your entire program will exit after any given operation is completed. This behavior is only desired for non-interactive CLI implementations, such as MUTN. Currently, most errors will result in the program exiting **even with this build tag**. Specific types of errors (such as config parsing/SSH dialing errors) have been made exempt from this behavior.
- `wsl`: Allows creating a Linux binary that can interact with the Windows clipboard (for WSL)
- `termux`: Allows creating a Linux binary that can interact with the Termux clipboard (for Android)
- `termux`: Allows creating an Android binary that can interact with the Termux clipboard (for Android)
## Required Arguments
libmutton-based password manager clients should accept at least one specific required argument, as well as another recommended one:
- `clipclear`: This argument is required for correct functionality. In order to clear the clipboard on a timer, libmutton-based password managers call another instance of their executable with the `clipclear` argument (e.g. `mutn clipclear`) with the intended clipboard contents provided via STDIN. If after 30 seconds the clipboard contents have not changed, they are cleared. Please accept a `clipclear` argument that is processed before the launch of any interactive interface. All this argument needs to do is call `core.ClipClearArgument()`.
- `init`: This argument is optional, but recommended for CLI interfaces. Some error messages request the user to use the `init` argument to fix configuration issues. If the argument does not exist, this may confuse the user.
## Required Global Variable Manipulation
libmutton provides a `PassphraseInputFunction` global variable that all clients must set to support passphrase-protected SSH identity files. This approach allows for different types of clients (CLI, GUI, TUI) to prompt for the passphrase in the most appropriate way.
## Required Argument (clipclear)
The `clipclear` argument should be accepted by all non-interactive CLI libmutton implementations (not required for interactive GUI/TUI implementations). In order to clear the clipboard on a timer, non-interactive libmutton-based password managers call another instance of their executable with the `clipclear` argument (e.g. `mutn clipclear`) with the intended clipboard contents provided via STDIN. If after 30 seconds the clipboard contents have not changed, they are cleared. Please accept a `clipclear` argument that calls `core.ClipClearArgument()`.
## Configuration
libmutton-based password manager clients should all share the same INI configuration file.