mirror of
https://github.com/rwinkhart/libmutton.git
synced 2026-08-28 12:56:31 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3905dc6e41 | ||
|
|
7c577b5778 | ||
|
|
aa10016f77 | ||
|
|
a39952489a | ||
|
|
b9175d7ff3 | ||
|
|
235f6b5ed4 | ||
|
|
1b02ccc056 | ||
|
|
3dc3a75967 | ||
|
|
cc58d3509f | ||
|
|
d023058630 | ||
|
|
fb2120c94e | ||
|
|
1a66a30bc1 | ||
|
|
591624eff6 | ||
|
|
3fdcc9e96d | ||
|
|
9fc9b208b6 | ||
|
|
41a12c6e4a | ||
|
|
bed23e987d | ||
|
|
dd622c59e2 | ||
|
|
362eb75dc4 | ||
|
|
d1b5c50d70 | ||
|
|
1c06713548 | ||
|
|
37a1a6049f | ||
|
|
cf5d5051fd | ||
|
|
0f03096ed2 | ||
|
|
d844fb1010 | ||
|
|
111324cc25 | ||
|
|
3daaf2d67a | ||
|
|
c0f55156d8 | ||
|
|
ca4913f054 | ||
|
|
4a0e0f8e9e | ||
|
|
87041a7bb3 | ||
|
|
d0f7b663d5 | ||
|
|
5f8789a0f0 | ||
|
|
2e3be57e3e | ||
|
|
4dab9f2c93 | ||
|
|
038cd3670d | ||
|
|
346fa35dc8 | ||
|
|
c4db7dfd59 | ||
|
|
2840ff3cf2 | ||
|
|
ca277ba773 | ||
|
|
5666974a7f | ||
|
|
c2fc911693 | ||
|
|
6e74e37e5b | ||
|
|
3dd07e7165 | ||
|
|
9ffd6d5cef | ||
|
|
a1c47b06a1 | ||
|
|
16aaa4f330 | ||
|
|
aa0e242748 | ||
|
|
40f0f35f45 | ||
|
|
74b8261bc8 | ||
|
|
50ab27c674 | ||
|
|
fa6f057b49 | ||
|
|
3c5db78da9 | ||
|
|
02c663447a | ||
|
|
c591601b6c | ||
|
|
e64da70edb | ||
|
|
35d053b46f | ||
|
|
cdf41c3d17 | ||
|
|
26db43eb29 | ||
|
|
162e277ad2 | ||
|
|
36bbf88690 | ||
|
|
84d227b803 | ||
|
|
8860ce4969 | ||
|
|
dfb5c10dcb | ||
|
|
491392562a | ||
|
|
fbfcdc5320 | ||
|
|
03c040b373 | ||
|
|
aec42cad96 | ||
|
|
0d66c26878 | ||
|
|
ac85577916 | ||
|
|
fb83d29a23 | ||
|
|
008088e2d6 | ||
|
|
e7293505bc | ||
|
|
f549b591c0 | ||
|
|
3404fc7482 | ||
|
|
21d60b8d0f | ||
|
|
826d94bc43 | ||
|
|
8807e8fe79 | ||
|
|
649b60e2fe | ||
|
|
a95bff8ef4 | ||
|
|
8f98e0b577 | ||
|
|
8f23589459 | ||
|
|
1dff1f463a | ||
|
|
4ed816cb11 | ||
|
|
451d695649 | ||
|
|
259d9a3309 | ||
|
|
51bb67f315 | ||
|
|
0f90e82514 | ||
|
|
44924737ab | ||
|
|
17223758b1 | ||
|
|
f40699076d | ||
|
|
a19a4aa1d5 | ||
|
|
456d20256c | ||
|
|
da95ede807 |
@@ -12,14 +12,10 @@ libmutton is a library for building simple, SSH-synchronized password managers i
|
||||
See the [developer guide](https://github.com/rwinkhart/libmutton/blob/main/wiki/developers.md).
|
||||
|
||||
# Roadmap
|
||||
#### Release v0.3.0
|
||||
- [ ] Add refresh/re-encrypt functionality
|
||||
#### Release v0.4.0
|
||||
#### Release v0.5.0
|
||||
- [ ] Implement "netpin" (quick-unlock)
|
||||
- [ ] Password aging support
|
||||
- [ ] Append UNIX timestamp to entry names
|
||||
#### Release v0.5.0
|
||||
- [ ] Swap to native (cascade) encryption (custom)
|
||||
- [ ] Implement "netpin" (quick-unlock) with new encryption
|
||||
#### Release v1.0.0
|
||||
- [ ] Create packaging scripts (libmuttonserver)
|
||||
- [ ] Stable source PKGBUILD
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package cfg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// loadConfig loads the libmutton.ini file and returns the configuration.
|
||||
// It is a utility function for ParseConfig and WriteConfig; do not call directly.
|
||||
func loadConfig() (*ini.File, error) {
|
||||
cfg, err := ini.Load(global.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to load libmutton.ini: " + err.Error())
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ParseConfig reads the libmutton.ini file and returns a slice of values for the specified keys.
|
||||
// Requires: valuesRequested (a slice of length 2 arrays each containing a section and a key name).
|
||||
// Returns: config (slice of values for the specified keys).
|
||||
// If requesting SSH config, request "LIBMUTTON/offlineMode" first to avoid errors.
|
||||
func ParseConfig(valuesRequested [][2]string) ([]string, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config []string
|
||||
for _, pair := range valuesRequested {
|
||||
value := cfg.Section(pair[0]).Key(pair[1]).String()
|
||||
// ensure specified key has a value
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("unable to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0])
|
||||
}
|
||||
config = append(config, value)
|
||||
|
||||
// notify requester immediately if in offline mode
|
||||
if pair[1] == "offlineMode" && pair[0] == "LIBMUTTON" && value == "true" {
|
||||
return config, nil
|
||||
}
|
||||
}
|
||||
|
||||
return config, err
|
||||
}
|
||||
|
||||
// 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),
|
||||
// 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) error {
|
||||
var cfg *ini.File
|
||||
var err error
|
||||
|
||||
if append {
|
||||
// load existing ini file
|
||||
cfg, err = loadConfig()
|
||||
if err != nil {
|
||||
return errors.New("unable to load existing libmutton.ini: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
// create empty ini container
|
||||
cfg = ini.Empty()
|
||||
}
|
||||
|
||||
// set all specified key-value pairs in their respective sections
|
||||
var section *ini.Section
|
||||
for _, trio := range valuesToWrite {
|
||||
if cfg.Section(trio[0]) == nil {
|
||||
// create and acquire section if it doesn't exist
|
||||
section, _ = cfg.NewSection(trio[0])
|
||||
} else {
|
||||
// acquire existing section
|
||||
section = cfg.Section(trio[0])
|
||||
}
|
||||
|
||||
// set key-value pair
|
||||
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
|
||||
setUmask(0077) // only give permissions to owner
|
||||
err = cfg.SaveTo(global.ConfigPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to save libmutton.ini: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package cfg
|
||||
|
||||
import "syscall"
|
||||
|
||||
// setUmask sets the file mode creation mask (umask) for the current process.
|
||||
// The call to syscall.Umask needs to be embedded in another function to allow
|
||||
// compilation on Windows.
|
||||
func setUmask(umask int) {
|
||||
syscall.Umask(umask)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package cfg
|
||||
|
||||
// setUmask is a dummy function on Windows.
|
||||
func setUmask(umask int) {
|
||||
return
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
type ByteInputFetcher func(prompt string) []byte
|
||||
|
||||
var (
|
||||
PassphraseInputFunction ByteInputFetcher // Clients should set this to a function that fetches hidden input from the user
|
||||
Home, _ = os.UserHomeDir()
|
||||
)
|
||||
|
||||
const (
|
||||
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)
|
||||
|
||||
AnsiError = "\033[38;5;9m"
|
||||
AnsiReset = "\033[0m"
|
||||
|
||||
ErrorRead = 101
|
||||
ErrorWrite = 102
|
||||
ErrorSyncProcess = 103
|
||||
ErrorServerConnection = 104
|
||||
ErrorTargetNotFound = 105
|
||||
ErrorTargetExists = 106
|
||||
ErrorTargetWrongType = 107
|
||||
ErrorDecryption = 108
|
||||
ErrorEncryption = 109
|
||||
ErrorClipboard = 110
|
||||
ErrorOther = 111
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
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
|
||||
|
||||
const (
|
||||
PathSeparator = "/" // Platform-specific path separator
|
||||
IsWindows = false // Platform indicator
|
||||
)
|
||||
|
||||
// enableVirtualTerminalProcessing is a dummy function on UNIX-like systems (only needed on Windows).
|
||||
// TODO Remove after migration off of GPG, as pinentry is responsible for disabling ANSI escape sequence interpretation.
|
||||
func enableVirtualTerminalProcessing() {
|
||||
return
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"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
|
||||
|
||||
const (
|
||||
PathSeparator = "\\" // Platform-specific path separator
|
||||
IsWindows = true // Platform indicator
|
||||
)
|
||||
|
||||
// enableVirtualTerminalProcessing ensures ANSI escape sequences are interpreted properly on Windows.
|
||||
// TODO Remove after migration off of GPG, as pinentry is responsible for disabling ANSI escape sequence interpretation.
|
||||
func enableVirtualTerminalProcessing() {
|
||||
stdout := syscall.Handle(os.Stdout.Fd())
|
||||
|
||||
var originalMode uint32
|
||||
syscall.GetConsoleMode(stdout, &originalMode)
|
||||
originalMode |= 0x0004
|
||||
|
||||
syscall.MustLoadDLL("kernel32").MustFindProc("SetConsoleMode").Call(uintptr(stdout), uintptr(originalMode))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build (android && !termux) || ios
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
// 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) error {
|
||||
clearClipboard := func() {
|
||||
clipboard.Write(clipboard.FmtText, []byte(""))
|
||||
back.Exit(0)
|
||||
}
|
||||
|
||||
// if assignedContents is empty, clear the clipboard immediately and unconditionally
|
||||
if assignedContents == "" {
|
||||
clearClipboard()
|
||||
return nil
|
||||
}
|
||||
|
||||
// wait 30 seconds before checking clipboard contents
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
newContents := clipboard.Read(clipboard.FmtText)
|
||||
|
||||
if assignedContents == strings.TrimRight(string(newContents), "\r\n") {
|
||||
clearClipboard()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build (!android && !ios) || termux
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// 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) error {
|
||||
cmdPaste, cmdClear := getClipCommands()
|
||||
|
||||
clearClipboard := func() error {
|
||||
err := cmdClear.Run()
|
||||
if err != nil {
|
||||
return errors.New("unable to clear clipboard")
|
||||
}
|
||||
back.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// if assignedContents is empty, clear the clipboard immediately and unconditionally
|
||||
if assignedContents == "" {
|
||||
err := clearClipboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wait 30 seconds before checking clipboard contents
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
newContents, err := cmdPaste.Output()
|
||||
if err != nil {
|
||||
return errors.New("unable to read clipboard contents")
|
||||
}
|
||||
|
||||
if assignedContents == strings.TrimRight(string(newContents), "\r\n") {
|
||||
err := clearClipboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// loadConfig loads the libmutton.ini file and returns the configuration.
|
||||
// It is a utility function for ParseConfig and WriteConfig; do not call directly.
|
||||
func loadConfig() *ini.File {
|
||||
cfg, err := ini.Load(ConfigPath)
|
||||
if err != nil {
|
||||
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: 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),
|
||||
// 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
|
||||
|
||||
for _, pair := range valuesRequested {
|
||||
value := cfg.Section(pair[0]).Key(pair[1]).String()
|
||||
|
||||
// ensure specified key has a value
|
||||
if value == "" {
|
||||
switch missingValueError {
|
||||
case "":
|
||||
err = fmt.Errorf("Failed to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0])
|
||||
case "0":
|
||||
Exit(0) // hard (expected) exit for CLI; GUI/TUI continue silently
|
||||
default:
|
||||
err = fmt.Errorf("%s", missingValueError)
|
||||
}
|
||||
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, err
|
||||
}
|
||||
|
||||
// GenDeviceIDList returns a pointer to a slice of all registered device IDs.
|
||||
// Requires: errorOnFail (set to true to throw an error if the devices directory cannot be read/does not exist)
|
||||
func GenDeviceIDList(errorOnFail bool) *[]fs.DirEntry {
|
||||
// create a slice of all registered devices
|
||||
deviceIDList, err := os.ReadDir(ConfigDir + PathSeparator + "devices")
|
||||
if err != nil {
|
||||
if errorOnFail {
|
||||
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
|
||||
}
|
||||
}
|
||||
return &deviceIDList
|
||||
}
|
||||
|
||||
// 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),
|
||||
// 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 {
|
||||
// load existing ini file
|
||||
cfg = loadConfig()
|
||||
} else {
|
||||
// create empty ini container
|
||||
cfg = ini.Empty()
|
||||
}
|
||||
|
||||
// set all specified key-value pairs in their respective sections
|
||||
var section *ini.Section
|
||||
for _, trio := range valuesToWrite {
|
||||
if cfg.Section(trio[0]) == nil {
|
||||
// create and aquire section if it doesn't exist
|
||||
section, _ = cfg.NewSection(trio[0])
|
||||
} else {
|
||||
// acquire existing section
|
||||
section = cfg.Section(trio[0])
|
||||
}
|
||||
|
||||
// set key-value pair
|
||||
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 {
|
||||
PrintError("Failed to save libmutton.ini: "+err.Error(), ErrorWrite, true)
|
||||
}
|
||||
}
|
||||
+79
-81
@@ -1,120 +1,118 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
steamtotp "github.com/fortis/go-steam-totp"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/crypt"
|
||||
)
|
||||
|
||||
// CopyArgument copies a field from an entry to the clipboard.
|
||||
func CopyArgument(targetLocation string, field int) {
|
||||
if isFile, _ := TargetIsFile(targetLocation, true, 2); isFile {
|
||||
// If field is -1, it will not continuously update the clipboard (one-time copy).
|
||||
func CopyArgument(targetLocation string, field int) error {
|
||||
// ensure targetLocation exists and is a file
|
||||
_, err := back.TargetIsFile(targetLocation, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decryptedEntry := DecryptGPG(targetLocation)
|
||||
var copySubject string // will store data to be copied
|
||||
decryptedEntry, err := crypt.DecryptFileToSlice(targetLocation)
|
||||
if err != nil {
|
||||
return errors.New("unable to decrypt entry: " + err.Error())
|
||||
}
|
||||
var copySubject string // will store data to be copied
|
||||
|
||||
// ensure field exists in entry
|
||||
if len(decryptedEntry) > field {
|
||||
// handle non-persistent TOTP copy
|
||||
var realField int
|
||||
if field == -1 {
|
||||
realField = 2
|
||||
} else {
|
||||
realField = field
|
||||
}
|
||||
|
||||
// ensure field is not empty
|
||||
if decryptedEntry[field] == "" {
|
||||
PrintError("Field is empty", ErrorTargetNotFound, true)
|
||||
}
|
||||
// ensure field exists in entry
|
||||
if len(decryptedEntry) > realField {
|
||||
|
||||
if field != 2 {
|
||||
copySubject = decryptedEntry[field]
|
||||
} else { // TOTP mode
|
||||
var secret string // stores secret for TOTP generation
|
||||
var forSteam bool // indicates whether to generate TOTP in Steam format
|
||||
|
||||
if strings.HasPrefix(decryptedEntry[2], "steam@") {
|
||||
secret = decryptedEntry[2][6:]
|
||||
forSteam = true
|
||||
} else {
|
||||
secret = decryptedEntry[2]
|
||||
}
|
||||
|
||||
fmt.Println("Clipboard will be kept up to date with the current TOTP code until this process is closed")
|
||||
|
||||
for { // keep token copied to clipboard, refresh on 30-second intervals
|
||||
currentTime := time.Now()
|
||||
copyString(true, GenTOTP(secret, currentTime, forSteam))
|
||||
// sleep until next 30-second interval
|
||||
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PrintError("Field does not exist in entry", ErrorTargetNotFound, true)
|
||||
// ensure field is not empty
|
||||
if decryptedEntry[realField] == "" {
|
||||
return errors.New("field is empty")
|
||||
}
|
||||
|
||||
// copy field to clipboard, launch clipboard clearing process
|
||||
copyString(false, copySubject)
|
||||
if realField != 2 {
|
||||
copySubject = decryptedEntry[realField]
|
||||
} else { // TOTP mode
|
||||
var secret string // stores secret for TOTP generation
|
||||
var forSteam bool // indicates whether to generate TOTP in Steam format
|
||||
|
||||
if strings.HasPrefix(decryptedEntry[2], "steam@") {
|
||||
secret = decryptedEntry[2][6:]
|
||||
forSteam = true
|
||||
} else {
|
||||
secret = decryptedEntry[2]
|
||||
}
|
||||
|
||||
fmt.Println("Clipboard will be kept up to date with the current TOTP code until this process is closed")
|
||||
|
||||
for { // keep token copied to clipboard, refresh on 30-second intervals
|
||||
currentTime := time.Now()
|
||||
token, err := GenTOTP(secret, currentTime, forSteam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = copyString(true, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if field == -1 {
|
||||
return nil // return early (for interactive clients)
|
||||
}
|
||||
// sleep until next 30-second interval
|
||||
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return errors.New("field does not exist in entry")
|
||||
}
|
||||
|
||||
// copy field to clipboard, launch clipboard clearing process
|
||||
err = copyString(false, copySubject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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() {
|
||||
assignedContents := clipScanner.Text()
|
||||
clipClearProcess(assignedContents)
|
||||
} else {
|
||||
func ClipClearArgument() error {
|
||||
assignedContents := back.ReadFromStdin()
|
||||
if assignedContents == "" {
|
||||
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()
|
||||
}
|
||||
err := clipClearProcess(assignedContents)
|
||||
return err
|
||||
}
|
||||
|
||||
// GenTOTP generates a TOTP token from a secret (supports standard and Steam TOTP).
|
||||
func GenTOTP(secret string, time time.Time, forSteam bool) string {
|
||||
func GenTOTP(secret string, time time.Time, forSteam bool) (string, error) {
|
||||
var totpToken string
|
||||
var err error
|
||||
|
||||
if forSteam {
|
||||
totpToken, err = steamtotp.GenerateAuthCode(secret, time)
|
||||
totpToken, err = totp.GenerateCodeCustom(secret, time, totp.ValidateOpts{Period: 30, Digits: 5, Encoder: otp.EncoderSteam})
|
||||
} else {
|
||||
totpToken, err = totp.GenerateCode(secret, time)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError("Error generating TOTP code", ErrorOther, true)
|
||||
return "", errors.New("unable to generate TOTP token: " + err.Error())
|
||||
}
|
||||
|
||||
return totpToken
|
||||
return totpToken, nil
|
||||
}
|
||||
|
||||
+9
-6
@@ -1,28 +1,31 @@
|
||||
//go:build darwin
|
||||
//go:build darwin && !ios
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// copyString copies a string to the clipboard.
|
||||
func copyString(continuous bool, copySubject string) {
|
||||
func copyString(continuous bool, copySubject string) error {
|
||||
cmd := exec.Command("pbcopy")
|
||||
WriteToStdin(cmd, copySubject)
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
|
||||
if !continuous {
|
||||
LaunchClipClearProcess(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
|
||||
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
|
||||
cmdClear := exec.Command("pbcopy")
|
||||
WriteToStdin(cmdClear, "")
|
||||
_ = back.WriteToStdin(cmdClear, "")
|
||||
return exec.Command("pbpaste"), cmdClear
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build (android && !termux) || ios
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
// copyString copies a string to the clipboard.
|
||||
func copyString(continuous bool, copySubject string) error {
|
||||
clipboard.Write(clipboard.FmtText, []byte(copySubject))
|
||||
if !continuous {
|
||||
LaunchClipClearProcess(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+8
-5
@@ -3,26 +3,29 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// copyString copies a string to the clipboard.
|
||||
func copyString(continuous bool, copySubject string) {
|
||||
func copyString(continuous bool, copySubject string) error {
|
||||
cmd := exec.Command("termux-clipboard-set")
|
||||
WriteToStdin(cmd, copySubject)
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
|
||||
if !continuous {
|
||||
LaunchClipClearProcess(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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, "")
|
||||
_ = back.WriteToStdin(cmdClear, "")
|
||||
return exec.Command("termux-clipboard-get"), cmdClear
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,35 +1,38 @@
|
||||
//go:build !windows && !darwin && !android && !termux && !wsl
|
||||
//go:build !windows && !darwin && !android && !ios && !termux && !wsl
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// copyString copies a string to the clipboard.
|
||||
func copyString(continuous bool, copySubject string) {
|
||||
func copyString(continuous bool, copySubject string) error {
|
||||
// determine whether to use wl-copy (Wayland) or xclip (X11)
|
||||
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)
|
||||
return errors.New("clipboard platform could not be determined")
|
||||
}
|
||||
|
||||
WriteToStdin(cmdCopy, copySubject)
|
||||
_ = back.WriteToStdin(cmdCopy, copySubject)
|
||||
err := cmdCopy.Run()
|
||||
if err != nil {
|
||||
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
|
||||
if !continuous {
|
||||
LaunchClipClearProcess(copySubject, isWayland)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
|
||||
|
||||
+4
-3
@@ -3,22 +3,23 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// copyString copies a string to the clipboard.
|
||||
func copyString(continuous bool, copySubject string) {
|
||||
func copyString(continuous bool, copySubject string) error {
|
||||
cmd := exec.Command("powershell.exe", "-c", fmt.Sprintf("echo '%s' | Set-Clipboard", strings.ReplaceAll(copySubject, "'", "''")))
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
PrintError("Failed to copy to clipboard: "+err.Error(), ErrorClipboard, true)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
|
||||
if !continuous {
|
||||
LaunchClipClearProcess(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
|
||||
|
||||
+21
-6
@@ -1,18 +1,33 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/crypt"
|
||||
)
|
||||
|
||||
// GetOldEntryData decrypts and returns old entry data (with all required lines present).
|
||||
func GetOldEntryData(targetLocation string, field int) []string {
|
||||
// ensure targetLocation exists
|
||||
TargetIsFile(targetLocation, true, 2)
|
||||
// This is a wrapper around the DecryptFileToSlice function that ensures all required lines are present in the returned slice.
|
||||
// This makes it ideal for editing entries, as it guarantees at least a baseline slice length.
|
||||
func GetOldEntryData(targetLocation string, field int) ([]string, error) {
|
||||
// ensure targetLocation exists and is a file
|
||||
_, err := back.TargetIsFile(targetLocation, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// read old entry data
|
||||
unencryptedEntry := DecryptGPG(targetLocation)
|
||||
decryptedEntry, err := crypt.DecryptFileToSlice(targetLocation)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to decrypt entry: " + err.Error())
|
||||
}
|
||||
|
||||
// return the old entry data with all required lines present
|
||||
if field > 0 {
|
||||
return ensureSliceLength(unencryptedEntry, field)
|
||||
return ensureSliceLength(decryptedEntry, field), nil
|
||||
} else {
|
||||
return unencryptedEntry
|
||||
return decryptedEntry, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !interactive
|
||||
|
||||
package core
|
||||
|
||||
import "os"
|
||||
|
||||
// Exit (hard) is meant to be used in non-interactive CLI implementations to exit the program after an operation.
|
||||
func Exit(code int) {
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//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) int {
|
||||
return code
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO GPG support is a temporary feature - It will be replaced with a different encryption scheme in the future
|
||||
|
||||
// DecryptGPG decrypts a GPG-encrypted file and returns the contents as a slice of (trimmed) strings.
|
||||
func DecryptGPG(targetLocation string) []string {
|
||||
cmd := exec.Command("gpg", "--pinentry-mode", "loopback", "-q", "-d", targetLocation)
|
||||
output, err := cmd.Output()
|
||||
|
||||
// ensure ANSI escape sequences are interpreted properly on Windows
|
||||
enableVirtualTerminalProcessing()
|
||||
|
||||
if err != nil {
|
||||
PrintError("Failed to decrypt \""+targetLocation+"\" - Ensure it is a valid GPG-encrypted file and that you entered your passphrase correctly", ErrorDecryption, true)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
PrintError("Failed to encrypt data - Ensure that you have a valid GPG ID set in libmutton.ini", ErrorEncryption, true)
|
||||
}
|
||||
return encryptedBytes
|
||||
}
|
||||
+93
-74
@@ -1,89 +1,108 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"cmp"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/cfg"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccycles"
|
||||
"github.com/rwinkhart/rcw/wrappers"
|
||||
)
|
||||
|
||||
// GpgUIDListGen generates a list of all GPG key IDs on the system and returns them as a slice of strings.
|
||||
func GpgUIDListGen() []string {
|
||||
cmd := exec.Command("gpg", "-k", "--with-colons")
|
||||
gpgOutputBytes, _ := cmd.Output()
|
||||
gpgOutputLines := strings.Split(string(gpgOutputBytes), "\n")
|
||||
var uidSlice []string
|
||||
for _, line := range gpgOutputLines {
|
||||
if strings.HasPrefix(line, "uid") {
|
||||
uid := strings.Split(line, ":")[9]
|
||||
uidSlice = append(uidSlice, uid)
|
||||
}
|
||||
}
|
||||
return uidSlice
|
||||
}
|
||||
|
||||
// GpgKeyGen generates a new GPG key and returns the key ID.
|
||||
func GpgKeyGen() string {
|
||||
gpgGenTempFile := CreateTempFile()
|
||||
defer func(name string) {
|
||||
_ = os.Remove(name) // error ignored; if the file could be created, it can probably be removed
|
||||
}(gpgGenTempFile.Name())
|
||||
|
||||
// create and write gpg-gen file
|
||||
unixTime := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
_, _ = gpgGenTempFile.WriteString(strings.Join([]string{"Key-Type: eddsa", "Key-Curve: ed25519", "Key-Usage: sign", "Subkey-Type: ecdh", "Subkey-Curve: cv25519", "Subkey-Usage: encrypt", "Name-Real: libmutton-" + unixTime, "Name-Comment: gpg-libmutton", "Name-Email: github.com/rwinkhart/libmutton", "Expire-Date: 0"}, "\n")) // error ignored; if the file could be created, it can probably be written to
|
||||
|
||||
// close gpg-gen file
|
||||
_ = gpgGenTempFile.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
|
||||
// generate GPG key based on gpg-gen file
|
||||
cmd := exec.Command("gpg", "-q", "--batch", "--generate-key", gpgGenTempFile.Name())
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
PrintError("Failed to generate GPG key: "+err.Error(), ErrorOther, true)
|
||||
}
|
||||
|
||||
return "libmutton-" + unixTime + " (gpg-libmutton) <github.com/rwinkhart/libmutton>"
|
||||
}
|
||||
|
||||
// DirInit creates the libmutton directories.
|
||||
// Returns: oldDeviceID (from before the directory reset; will be FSMisc if there is no pre-existing ID).
|
||||
func DirInit(preserveOldConfigDir bool) string {
|
||||
// create EntryRoot
|
||||
err := os.MkdirAll(EntryRoot, 0700)
|
||||
if err != nil {
|
||||
PrintError("Failed to create \""+EntryRoot+"\": "+err.Error(), ErrorWrite, true)
|
||||
}
|
||||
|
||||
// get old device ID before its potential removal
|
||||
oldDeviceIDList := GenDeviceIDList(false) // errorOnFail is false so that nil is received when the devices directory does not exist
|
||||
var oldDeviceID string
|
||||
if oldDeviceIDList != nil && len(*oldDeviceIDList) > 0 { // ensure not derferencing nil, which occurs when the devices directory does not exist
|
||||
oldDeviceID = (*oldDeviceIDList)[0].Name()
|
||||
// LibmuttonInit creates the libmutton config structure based on user input.
|
||||
// rcwPassword and clientSpecificIniData can be left blank if not needed.
|
||||
func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][3]string, rcwPassword []byte, preserveOldConfigDir bool, forceOfflineMode bool) error {
|
||||
var r string
|
||||
if !forceOfflineMode {
|
||||
r = strings.ToLower(inputCB("Configure SSH settings (for synchronization)? (Y/n)"))
|
||||
} else {
|
||||
oldDeviceID = FSMisc // indicates to server that no device ID is being replaced
|
||||
r = "n"
|
||||
}
|
||||
if len(r) > 0 && r[0] == 'n' {
|
||||
// initialize libmutton directories
|
||||
_, err := global.DirInit(preserveOldConfigDir)
|
||||
if err != nil {
|
||||
return errors.New("unable to initialize libmutton directories: " + err.Error())
|
||||
}
|
||||
// write config file
|
||||
if len(clientSpecificIniData) > 0 {
|
||||
err = cfg.WriteConfig(append(clientSpecificIniData, [][3]string{{"LIBMUTTON", "offlineMode", "true"}}...), nil, false)
|
||||
} else {
|
||||
err = cfg.WriteConfig([][3]string{{"LIBMUTTON", "offlineMode", "true"}}, nil, false)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("unable to write config file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
// ensure ssh key file exists (and is a file)
|
||||
fallbackSSHKey := back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "id_ed25519"
|
||||
sshKeyPath := cmp.Or(back.ExpandPathWithHome(inputCB(back.AnsiBold+"Note:"+back.AnsiReset+" Only key-based authentication is supported (keys may optionally be password-protected).\n The remote server must already be in your ~"+global.PathSeparator+".ssh"+global.PathSeparator+"known_hosts file.\n\nSSH private identity file path (falls back to \""+fallbackSSHKey+"\"):")), fallbackSSHKey)
|
||||
_, err := back.TargetIsFile(sshKeyPath, true)
|
||||
if err != nil {
|
||||
return errors.New("unable to find SSH identity file: " + err.Error())
|
||||
}
|
||||
|
||||
// remove existing config directory (if it exists and not in append mode)
|
||||
if !preserveOldConfigDir {
|
||||
_, isAccessible := TargetIsFile(ConfigDir, false, 1)
|
||||
if isAccessible {
|
||||
err = os.RemoveAll(ConfigDir)
|
||||
if err != nil {
|
||||
PrintError("Failed to remove existing config directory: "+err.Error(), ErrorWrite, true)
|
||||
}
|
||||
// get other ssh info from user
|
||||
var sshKeyProtected bool
|
||||
r = strings.ToLower(inputCB("Is the identity file password-protected? (y/N)"))
|
||||
if len(r) > 0 && r[0] == 'y' {
|
||||
sshKeyProtected = true
|
||||
}
|
||||
sshUser := inputCB("Remote SSH username:")
|
||||
sshIP := inputCB("Remote SSH IP/domain:")
|
||||
sshPort := inputCB("Remote SSH port:")
|
||||
|
||||
// perform operations based on collected user input
|
||||
//// initialize libmutton directories
|
||||
oldDeviceID, err := global.DirInit(preserveOldConfigDir)
|
||||
if err != nil {
|
||||
return errors.New("unable to initialize libmutton directories: " + err.Error())
|
||||
}
|
||||
//// write config file
|
||||
//// temporarily assign sshEntryRoot and sshIsWindows to null to pass initial device ID registration
|
||||
err = cfg.WriteConfig(append(
|
||||
clientSpecificIniData,
|
||||
[][3]string{
|
||||
{"LIBMUTTON", "offlineMode", "false"},
|
||||
{"LIBMUTTON", "sshUser", sshUser},
|
||||
{"LIBMUTTON", "sshIP", sshIP},
|
||||
{"LIBMUTTON", "sshPort", sshPort},
|
||||
{"LIBMUTTON", "sshKey", sshKeyPath},
|
||||
{"LIBMUTTON", "sshKeyProtected", strconv.FormatBool(sshKeyProtected)},
|
||||
{"LIBMUTTON", "sshEntryRoot", "null"},
|
||||
{"LIBMUTTON", "sshIsWindows", "false"}}...), nil, false)
|
||||
if err != nil {
|
||||
return errors.New("unable to write config file: " + err.Error())
|
||||
}
|
||||
// generate and register device ID
|
||||
sshEntryRoot, sshIsWindows, err := synccycles.DeviceIDGen(oldDeviceID, "")
|
||||
if err != nil {
|
||||
return errors.New("unable to generate device ID: " + err.Error())
|
||||
}
|
||||
err = cfg.WriteConfig([][3]string{{"LIBMUTTON", "sshEntryRoot", sshEntryRoot}, {"LIBMUTTON", "sshIsWindows", sshIsWindows}}, nil, true)
|
||||
if err != nil {
|
||||
return errors.New("unable to write config file: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// create config directory w/devices subdirectory
|
||||
err = os.MkdirAll(ConfigDir+PathSeparator+"devices", 0700)
|
||||
if err != nil {
|
||||
PrintError("Failed to create \""+ConfigDir+"\": "+err.Error(), ErrorWrite, true)
|
||||
// generate rcw sanity check file (if requested)
|
||||
if len(rcwPassword) > 0 {
|
||||
err := RCWSanityCheckGen(rcwPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return oldDeviceID
|
||||
// RCWSanityCheckGen generates the RCW sanity check file for libmutton.
|
||||
func RCWSanityCheckGen(password []byte) error {
|
||||
err := wrappers.GenSanityCheck(global.ConfigDir+global.PathSeparator+"sanity.rcw", password)
|
||||
if err != nil {
|
||||
return errors.New("unable to generate sanity check file: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
//go:build (windows || darwin || android || termux || wsl) && !interactive
|
||||
//go:build (windows || darwin || android || ios || termux || wsl) && !interactive
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "clipclear")
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
_ = cmd.Start()
|
||||
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !darwin && !android && !termux && !wsl && !interactive
|
||||
//go:build !windows && !darwin && !android && !ios && !termux && !wsl && !interactive
|
||||
|
||||
package core
|
||||
|
||||
@@ -6,17 +6,15 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "clipclear", strconv.FormatBool(isWayland))
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
_ = cmd.Start()
|
||||
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build (windows || darwin || android || termux || wsl) && interactive
|
||||
//go:build (windows || darwin || android || ios || termux || wsl) && interactive
|
||||
|
||||
package core
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !darwin && !android && !termux && !wsl && interactive
|
||||
//go:build !windows && !darwin && !android && !ios && !termux && !wsl && interactive
|
||||
|
||||
package core
|
||||
|
||||
|
||||
+124
-135
@@ -1,81 +1,122 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/crypt"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
"github.com/rwinkhart/rcw/wrappers"
|
||||
)
|
||||
|
||||
// TargetIsFile checks if the targetLocation is a file, directory, or is inaccessible.
|
||||
// Requires: failCondition (0 = fail on inaccessible, 1 = fail on inaccessible&file, 2 = fail on inaccessible&directory).
|
||||
// Returns: isFile, isAccessible.
|
||||
func TargetIsFile(targetLocation string, errorOnFail bool, failCondition uint8) (bool, bool) {
|
||||
targetInfo, err := os.Stat(targetLocation)
|
||||
if err != nil {
|
||||
if errorOnFail {
|
||||
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 {
|
||||
PrintError("\""+targetLocation+"\" is a directory", ErrorTargetWrongType, true)
|
||||
}
|
||||
return false, true
|
||||
} else {
|
||||
if errorOnFail && failCondition == 1 {
|
||||
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) {
|
||||
encryptedBytes := EncryptGPG(entryData)
|
||||
err := os.WriteFile(targetLocation, encryptedBytes, 0600)
|
||||
func WriteEntry(targetLocation string, decBytes []byte) error {
|
||||
err := os.WriteFile(targetLocation, crypt.EncryptBytes(decBytes), 0600)
|
||||
if err != nil {
|
||||
PrintError("Failed to write to file: "+err.Error(), ErrorWrite, true)
|
||||
return errors.New("unable to write to file: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
PrintError("Failed to access stdin for system command: "+err.Error(), ErrorOther, true)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func(stdin io.WriteCloser) {
|
||||
_ = stdin.Close() // error ignored; if stdin could be accessed, it can probably be closed
|
||||
}(stdin)
|
||||
_, _ = io.WriteString(stdin, input)
|
||||
}()
|
||||
}
|
||||
|
||||
// CreateTempFile creates a temporary file and returns a pointer to it.
|
||||
func CreateTempFile() *os.File {
|
||||
tempFile, err := os.CreateTemp("", "*.markdown")
|
||||
if err != nil {
|
||||
PrintError("Failed to create temporary file: "+err.Error(), ErrorWrite, true)
|
||||
}
|
||||
return tempFile
|
||||
}
|
||||
|
||||
// RemoveTrailingEmptyStrings removes empty strings from the end of a slice.
|
||||
func RemoveTrailingEmptyStrings(slice []string) []string {
|
||||
for i := len(slice) - 1; i >= 0; i-- {
|
||||
if slice[i] != "" {
|
||||
return slice[:i+1]
|
||||
// EntryRefresh re-encrypts all libmutton entries with a new password
|
||||
// and optimizes each entry to ensure they are as slim as possible.
|
||||
// This includes stripping trailing whitespace/newlines/carriage returns
|
||||
// from each field and running each note through ClampTrailingWhitespace
|
||||
// to ensure each note line is optimized as possible without breaking
|
||||
// Markdown formatting.
|
||||
// Be sure to verify passwords before using as input for this function!!
|
||||
func EntryRefresh(oldRCWPassword, newRCWPassword []byte, removeOldDir bool) error {
|
||||
// ensure global.EntryRoot+"-new" and global.EntryRoot-"old" do not exist
|
||||
dirEnds := []string{"-new", "-old"}
|
||||
for i, dirEnd := range dirEnds {
|
||||
if i == 1 && !removeOldDir {
|
||||
if _, err := os.Stat(global.EntryRoot + "-old"); !os.IsNotExist(err) {
|
||||
return errors.New("unable to refresh entries: \"" + global.EntryRoot + "-old\" already exists")
|
||||
}
|
||||
}
|
||||
err := os.RemoveAll(global.EntryRoot + dirEnd)
|
||||
if err != nil {
|
||||
return errors.New("unable to remove \"" + global.EntryRoot + dirEnd + "\": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// create output directory structure (global.EntryRoot + "-new"/*)
|
||||
entries, folders, err := synccommon.WalkEntryDir()
|
||||
if err != nil {
|
||||
return errors.New("unable to walk entry directory: " + err.Error())
|
||||
}
|
||||
for _, folder := range folders {
|
||||
fullPath := global.EntryRoot + "-new" + strings.ReplaceAll(folder, "/", global.PathSeparator)
|
||||
err := os.MkdirAll(fullPath, 0700)
|
||||
if err != nil {
|
||||
return errors.New("unable to create temporary directory \"" + fullPath + "\": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// decrypt, optimize, and re-encrypt each entry
|
||||
for _, entryName := range entries {
|
||||
targetLocation := global.TargetLocationFormat(entryName)
|
||||
encBytes, err := os.ReadFile(targetLocation)
|
||||
if err != nil {
|
||||
return errors.New("unable to open \"" + targetLocation + "\" for decryption: " + err.Error())
|
||||
}
|
||||
decBytes, err := wrappers.Decrypt(encBytes, oldRCWPassword)
|
||||
decryptedEntry := strings.Split(string(decBytes), "\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// strip trailing whitespace...
|
||||
fieldsLength := len(decryptedEntry)
|
||||
if fieldsLength < 4 {
|
||||
fieldsMain := back.RemoveTrailingEmptyStrings(decryptedEntry)
|
||||
// ...from each non-note field
|
||||
for i, line := range fieldsMain {
|
||||
fieldsMain[i] = strings.TrimRight(line, " \t\r\n")
|
||||
}
|
||||
decryptedEntry = fieldsMain
|
||||
} else {
|
||||
fieldsMain := decryptedEntry[:4]
|
||||
fieldsNote := back.RemoveTrailingEmptyStrings(decryptedEntry[4:])
|
||||
// ...from each non-note field
|
||||
for i, line := range fieldsMain {
|
||||
fieldsMain[i] = strings.TrimRight(line, " \t\r\n")
|
||||
}
|
||||
// ...and from each note line (preserve Markdown formatting)
|
||||
ClampTrailingWhitespace(fieldsNote)
|
||||
|
||||
// re-combine fields
|
||||
decryptedEntry = append(fieldsMain, fieldsNote...)
|
||||
}
|
||||
|
||||
// re-encrypt the entry with the new password
|
||||
encBytes = wrappers.Encrypt([]byte(strings.Join(decryptedEntry, "\n")), newRCWPassword)
|
||||
|
||||
// write the entry to the new directory
|
||||
err = os.WriteFile(global.EntryRoot+"-new"+strings.ReplaceAll(entryName, "/", global.PathSeparator), encBytes, 0600)
|
||||
if err != nil {
|
||||
return errors.New("unable to write to file: " + err.Error())
|
||||
}
|
||||
|
||||
// generate new sanity check file
|
||||
err = RCWSanityCheckGen(newRCWPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// swap the new directory with the old one
|
||||
err = os.Rename(global.EntryRoot, global.EntryRoot+"-old")
|
||||
if err != nil {
|
||||
return errors.New("unable to rename old directory: " + err.Error())
|
||||
}
|
||||
err = os.Rename(global.EntryRoot+"-new", global.EntryRoot)
|
||||
if err != nil {
|
||||
return errors.New("unable to rename new directory: " + err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -84,9 +125,9 @@ func RemoveTrailingEmptyStrings(slice []string) []string {
|
||||
func ClampTrailingWhitespace(note []string) {
|
||||
for i, line := range note {
|
||||
// remove trailing tabs, carriage returns, and newlines
|
||||
note[i] = strings.TrimRight(line, "\t\r\n")
|
||||
line = strings.TrimRight(line, "\t\r\n")
|
||||
|
||||
// determine the number of trailing spaces
|
||||
// determine the number of trailing spaces in the trimmed line
|
||||
var endSpacesCount int
|
||||
for j := len(line) - 1; j >= 0; j-- {
|
||||
if line[j] != ' ' {
|
||||
@@ -98,9 +139,10 @@ func ClampTrailingWhitespace(note []string) {
|
||||
// remove single spaces, truncate multiple spaces (leave two for Markdown formatting)
|
||||
switch endSpacesCount {
|
||||
case 0:
|
||||
// do nothing
|
||||
// no trailing spaces
|
||||
note[i] = line
|
||||
case 1:
|
||||
// remove the trailing space
|
||||
// remove the single trailing space
|
||||
note[i] = strings.TrimRight(line, " ")
|
||||
default:
|
||||
// truncate the trailing spaces to two
|
||||
@@ -109,57 +151,22 @@ func ClampTrailingWhitespace(note []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// StringGen generates a random string of a specified length and complexity.
|
||||
// Requires: complexity (minimum percentage of special characters to be returned in the generated string; only impacts complex strings),
|
||||
// safeForFileName: (if true, the generated string will only contain special characters that are safe for file names; only impacts complex strings).
|
||||
func StringGen(length int, complex bool, complexity float64, safeForFileName bool) string {
|
||||
var actualSpecialChars int // track the number of special characters in the generated string
|
||||
var minSpecialChars int // track the minimum number of special characters to accept
|
||||
var extendedCharset string // additions to character set used for complex strings
|
||||
|
||||
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // default character set used for all strings
|
||||
const extendedCharsetFiles = "!#$%&'()+,-.;=@[]^_`{}~" // additional special characters for complex strings (safe in file names)
|
||||
const extendedCharsetPassword = "\"*:><?/\\|" // additional special characters for complex strings (NOT safe in file names)
|
||||
if complex {
|
||||
minSpecialChars = int(math.Round(float64(length) * complexity)) // determine minimum number of special characters to accept
|
||||
if !safeForFileName {
|
||||
extendedCharset = extendedCharsetFiles + extendedCharsetPassword
|
||||
} else {
|
||||
extendedCharset = extendedCharsetFiles
|
||||
}
|
||||
charset += extendedCharset
|
||||
// EntryAddPrecheck ensures the directory meant to contain a new
|
||||
// entry exists and that the target entry location is not already used.
|
||||
// Returns: statusCode (0 = success, 1 = target location already exists, 2 = containing directory is invalid).
|
||||
func EntryAddPrecheck(targetLocation string) (uint8, error) {
|
||||
// ensure target location does not already exist
|
||||
isAccessible, _ := back.TargetIsFile(targetLocation, false) // error is ignored because dir/file status is irrelevant
|
||||
if isAccessible {
|
||||
return 1, errors.New("target location already exists")
|
||||
}
|
||||
|
||||
// loop until a string of the desired complexity is generated
|
||||
for {
|
||||
// generate a random string
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
val, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
result[i] = charset[val.Int64()]
|
||||
}
|
||||
|
||||
// return early if the string is not complex
|
||||
if !complex {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// count the number of special characters in the generated string
|
||||
for _, char := range string(result) {
|
||||
if strings.ContainsRune(extendedCharset, char) {
|
||||
actualSpecialChars++
|
||||
}
|
||||
}
|
||||
|
||||
// return the generated string if it contains enough special characters
|
||||
if actualSpecialChars >= minSpecialChars {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// reset special character counter
|
||||
fmt.Println("Regenerating string until desired complexity is achieved...")
|
||||
actualSpecialChars = 0
|
||||
// ensure target containing directory exists and is not a file
|
||||
containingDir := targetLocation[:strings.LastIndex(targetLocation, global.PathSeparator)]
|
||||
_, err := back.TargetIsFile(containingDir, false)
|
||||
if err != nil {
|
||||
return 2, errors.New("\"" + containingDir + "\" is not a valid containing directory: " + err.Error())
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// EntryIsNotEmpty iterates through entryData and returns true if any line is not empty.
|
||||
@@ -171,21 +178,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package crypt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/rcw/daemon"
|
||||
"github.com/rwinkhart/rcw/wrappers"
|
||||
)
|
||||
|
||||
var Daemonize = true
|
||||
var RetryPassword = true
|
||||
|
||||
// RCWDArgument reads the password from stdin and caches it via an RCW daemon.
|
||||
func RCWDArgument() {
|
||||
password := back.ReadFromStdin()
|
||||
if password == "" {
|
||||
os.Exit(0)
|
||||
}
|
||||
daemon.Start([]byte(password))
|
||||
}
|
||||
|
||||
// DecryptFileToSlice decrypts an RCW wrapped file and returns the contents as a slice of (trimmed) strings.
|
||||
func DecryptFileToSlice(targetLocation string) ([]string, error) {
|
||||
// read encrypted file
|
||||
encBytes, err := os.ReadFile(targetLocation)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to open \"" + targetLocation + "\" for decryption: " + err.Error())
|
||||
}
|
||||
|
||||
// decrypt data using RCW daemon
|
||||
password := launchRCWDProcess()
|
||||
if password == nil {
|
||||
// if daemon is already running, use it to decrypt the data
|
||||
return strings.Split(string(daemon.GetDec(encBytes)), "\n"), nil
|
||||
}
|
||||
// if the daemon is not already running, use wrappers.Decrypt
|
||||
// directly to avoid waiting for socket file creation
|
||||
decBytes, err := wrappers.Decrypt(encBytes, password)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to decrypt \"" + targetLocation + "\": " + err.Error())
|
||||
}
|
||||
return strings.Split(string(decBytes), "\n"), nil
|
||||
}
|
||||
|
||||
// EncryptBytes encrypts a byte slice using RCW and returns the encrypted data.
|
||||
func EncryptBytes(decBytes []byte) []byte {
|
||||
password := launchRCWDProcess()
|
||||
if password == nil {
|
||||
// if daemon is already running, use it to encrypt the data
|
||||
return daemon.GetEnc(decBytes)
|
||||
}
|
||||
// if the daemon is not already running, use wrappers.Encrypt
|
||||
// directly to avoid waiting for socket file creation
|
||||
return wrappers.Encrypt(decBytes, password)
|
||||
}
|
||||
|
||||
// launchRCWDProcess launches an RCW daemon to cache a password.
|
||||
// If the daemon is not already running OR if not running in daemonize mode,
|
||||
// it collects and returns the password (otherwise returns nil).
|
||||
func launchRCWDProcess() []byte {
|
||||
if Daemonize && daemon.IsOpen() {
|
||||
return nil
|
||||
}
|
||||
var password []byte
|
||||
if RetryPassword {
|
||||
for {
|
||||
password = global.GetPassword("RCW Password:")
|
||||
err := wrappers.RunSanityCheck(global.ConfigDir+global.PathSeparator+"sanity.rcw", password)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
fmt.Println(back.AnsiError + "Incorrect password" + back.AnsiReset)
|
||||
}
|
||||
} else {
|
||||
// in this mode, it is up to the client to perform the sanity check
|
||||
password = global.GetPassword("RCW Password:")
|
||||
}
|
||||
|
||||
if Daemonize {
|
||||
cmd := exec.Command(os.Args[0], "startrcwd")
|
||||
_ = back.WriteToStdin(cmd, string(password))
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
return password
|
||||
}
|
||||
@@ -1,8 +1,103 @@
|
||||
**libmutton v0.4.1**
|
||||
October 26, 2025
|
||||
|
||||
This is a dependency bump release with only minor changes for developers.
|
||||
|
||||
## Breaking (for developers)
|
||||
- (b9175d7ff3bea3bc703c20468defebcb1ac7e412) Standardized on "password" rather than a mix of "passphrase"/"password"
|
||||
- Some function names changed as a result
|
||||
- (aa10016f77b0be66ab75279df967f5d080d2b826) Developers using `LibmuttonInit` can now force clients into offline mode programmatically
|
||||
- This means `LibmuttonInit` now requires an extra parameter
|
||||
|
||||
## Dependencies
|
||||
- Bumped (direct/replaced)
|
||||
- Go: v1.24.6 => v1.25.3
|
||||
- github.com/pkg/sftp: v1.13.9 => v1.13.10
|
||||
- golang.org/x/crypto: v0.41.0 => v0.43.0
|
||||
- github.com/rwinkhart/sys: v0.35.0 => v0.37.0
|
||||
|
||||
---
|
||||
|
||||
**libmutton v0.4.0**
|
||||
August 17, 2025
|
||||
|
||||
This is the largest update to libmutton yet, and as such, these patch notes are non-exhaustive.
|
||||
Many minor changes and fixes to features mentioned in these patch notes have been made.
|
||||
Please see the commit history for a more complete list of changes.
|
||||
|
||||
## Breaking (for users)
|
||||
- GPG has been entirely replaced with [RCW](https://github.com/rwinkhart/rcw)
|
||||
- All entries created in previous versions must be converted using [this conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0)
|
||||
- Steam TOTP keys now must be in base32 format
|
||||
- (3c5db78da9861e09112ac308c086c2d19f2abfeb) Release binaries of `libmuttonserver` now target x86_64_v2 and arm64v8.7
|
||||
- (3dd07e7165874e323586c21a14853935bb3dc61c) Offline mode is now manually specified in libmutton.ini
|
||||
|
||||
## Breaking (for developers)
|
||||
- libmutton has been further modularized
|
||||
- The `core` package has been split into `core`, `global`, `crypt`, and `cfg`
|
||||
- The `sync` package has been split into `syncclient`, `syncserver`, `synccommon`, and `synccycles`
|
||||
- This decreases the size of the server binary and allows for more modular clients
|
||||
- (40f0f35f452008997bb5ecfb72f496646c42ba06) Errors are now returned, rather than printed
|
||||
|
||||
## Features
|
||||
- (111324cc25e84a5bd8b6b8206b90b00394311590) Deletions (server-side) now follow the client through device ID changes
|
||||
- (ca277ba7735dd75cc94b26c1c2357f7d3192e26b) Added the `EntryRefresh()` function for re-encrypting entries with a new passphrase/optimizing bloated entries
|
||||
- (74b8261bc88fc122a25df5207b34edc3dee81e9a) Added the `LibmuttonInit()` function to ensure clients initialize libmutton as intended
|
||||
- (bed23e987d89b133204991c2c34b8fa1e8e335c9) (41a12c6e4a7c09b22fac0faf6bdef5993264f5de) Added iOS clipboard support
|
||||
- (3fdcc9e96d71bb4bef3baf9f43a627782237b142) (1a66a30bc184528ec971306497cca3597072202a) (d023058630958842f0f521ba2bef43b12955b9d8) Support one-time TOTP copy
|
||||
- (fb2120c94ebc69f7ac8e15163f1e27b2c8057d9a) Support custom device ID prefixes
|
||||
|
||||
## Fixes
|
||||
- (50ab27c6749d13fc6842d92259f8e0fd8fa5340c) libmutton.ini is now always created with 0600 permissions on *nix platforms
|
||||
- (4a0e0f8e9e96805b27232001bce81336ac2e6a4d) Old devices IDs are no longer removed from clients when registration of a new device ID fails
|
||||
|
||||
## Dependencies
|
||||
- Dropped
|
||||
- github.com/fortis/go-steam-totp (functionality now covered by github.com/pquerna/otp)
|
||||
- Bumped (direct)
|
||||
- Go: v1.24.2 => v1.24.6
|
||||
- github.com/pquerna/otp: v1.4.1-0.20231130234153-3357de7c0481 => v1.5.0
|
||||
- golang.design/x/clipboard: v0.7.0 => v0.7.1
|
||||
- golang.org/x/crypto: v0.37.0 => v0.41.0
|
||||
- Added (direct)
|
||||
- github.com/rwinkhart/go-boilerplate v0.1.0
|
||||
- github.com/rwinkhart/rcw v0.2.2
|
||||
|
||||
---
|
||||
|
||||
**libmutton v0.3.1**
|
||||
April 21, 2025
|
||||
|
||||
## Features
|
||||
- (da95ede80704e667f75372e2142de4cef1dd42da) Added `EntryAddPrecheck()` utility function for ensuring the target locations for new entries are valid
|
||||
- (17223758b11eae301106f398fe2a8815a04f2d62) (44924737ab0830ad49e90c1d1930b05bba307284) (0f90e8251413af2be73f56d071c14af6c00cdb79) Added initial native Android support
|
||||
- (51bb67f3150e70c2c8e8dc3cc72de5666d7c058a) Split device ID retrieval into dedicated exported function, `GetCurrentDeviceID`
|
||||
- (4ed816cb112686d995b1cb7ae8f5df990117ae4d) Allow more control over string generation to improve generated password compatibility
|
||||
|
||||
## Fixes
|
||||
- (51bb67f3150e70c2c8e8dc3cc72de5666d7c058a) Fixed clients having multiple device IDs after failed registrations
|
||||
- (451d695649c659a510a117254a9311fe621082b1) Fixed inability to add a folder on the server if the folder was already created on a client that failed to sync
|
||||
|
||||
## Dependencies
|
||||
- Bumps (direct and indirect)
|
||||
- Go: v1.24.0 => v1.24.2
|
||||
- github.com/pkg/sftp: v1.13.7 => v1.13.9
|
||||
- golang.org/x/crypto: v0.34.0 => v0.37.0
|
||||
- golang.org/x/sys: v0.30.0 => v0.32.0
|
||||
- New
|
||||
- Android builds only
|
||||
- golang.design/x/clipboard v0.7.0
|
||||
- golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0
|
||||
- golang.org/x/image v0.26.0
|
||||
- golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7
|
||||
|
||||
---
|
||||
|
||||
**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
|
||||
- (f9aa2fc374b77fc8325d0c7644ffc9c96169c3c6) (d2034b458b702b8f1664bc188c1840040ec0f704) (81b78068a75a23c73be607cbe6d4dc8b1539f831) (e9d0c3704362c8963e63a6c3b70d036f3e6c2493) `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
|
||||
@@ -12,7 +107,6 @@ February 22, 2025
|
||||
- (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
|
||||
@@ -26,7 +120,7 @@ February 22, 2025
|
||||
## 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
|
||||
- (e9d0c3704362c8963e63a6c3b70d036f3e6c2493) `core.PrintError` can be used to decrease the overall size of client binaries through code re-use
|
||||
|
||||
## Dependencies
|
||||
- Bumps (direct and indirect)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package global
|
||||
|
||||
type ByteInputFetcher func(prompt string) []byte
|
||||
|
||||
var (
|
||||
GetPassword ByteInputFetcher // Clients should set this to a function that fetches hidden input from the user
|
||||
)
|
||||
|
||||
const (
|
||||
LibmuttonVersion = "0.4.1" // 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)
|
||||
|
||||
ErrorSyncProcess = 104
|
||||
ErrorDecryption = 105
|
||||
ErrorEncryption = 106
|
||||
ErrorClipboard = 107
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package global
|
||||
|
||||
import "github.com/rwinkhart/go-boilerplate/back"
|
||||
|
||||
var (
|
||||
EntryRoot = back.Home + "/.local/share/libmutton" // Path to libmutton entry directory
|
||||
ConfigDir = back.Home + "/.config/libmutton" // Path to libmutton configuration directory
|
||||
ConfigPath = ConfigDir + "/libmutton.ini" // Path to libmutton configuration file
|
||||
)
|
||||
|
||||
const (
|
||||
PathSeparator = "/" // Platform-specific path separator
|
||||
IsWindows = false // Platform indicator
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package global
|
||||
|
||||
import "github.com/rwinkhart/go-boilerplate/back"
|
||||
|
||||
var (
|
||||
EntryRoot = back.Home + "\\AppData\\Local\\libmutton\\entries" // Path to libmutton entry directory
|
||||
ConfigDir = back.Home + "\\AppData\\Local\\libmutton\\config" // Path to libmutton configuration directory
|
||||
ConfigPath = ConfigDir + "\\libmutton.ini" // Path to libmutton configuration file
|
||||
)
|
||||
|
||||
const (
|
||||
PathSeparator = "\\" // Platform-specific path separator
|
||||
IsWindows = true // Platform indicator
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// GetCurrentDeviceID returns the current device ID or
|
||||
// FSMisc if there is no device ID (e.g. first run).
|
||||
func GetCurrentDeviceID() (string, error) {
|
||||
deviceIDList, err := GenDeviceIDList()
|
||||
if err != nil {
|
||||
return "", errors.New("unable to generate device ID list: " + err.Error())
|
||||
}
|
||||
var deviceID string
|
||||
if len(deviceIDList) > 0 {
|
||||
deviceID = (deviceIDList)[0].Name()
|
||||
} else {
|
||||
deviceID = FSMisc // indicates to server that no device ID is being replaced
|
||||
}
|
||||
return deviceID, nil
|
||||
}
|
||||
|
||||
// GenDeviceIDList returns a slice of all registered device IDs.
|
||||
// Requires: errorOnFail (set to true to throw an error if the devices directory cannot be read/does not exist)
|
||||
func GenDeviceIDList() ([]fs.DirEntry, error) {
|
||||
// create a slice of all registered devices
|
||||
deviceIDList, err := os.ReadDir(ConfigDir + PathSeparator + "devices")
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to read devices directory: " + err.Error())
|
||||
}
|
||||
return deviceIDList, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// DirInit creates the libmutton directories.
|
||||
// Returns: oldDeviceID (from before the directory reset; will be FSMisc if there is no pre-existing ID).
|
||||
func DirInit(preserveOldConfigDir bool) (string, error) {
|
||||
// create EntryRoot
|
||||
err := os.MkdirAll(EntryRoot, 0700)
|
||||
if err != nil {
|
||||
return "", errors.New("unable to create \"" + EntryRoot + "\": " + err.Error())
|
||||
}
|
||||
|
||||
// get old device ID before its potential removal
|
||||
oldDeviceID, err := GetCurrentDeviceID()
|
||||
if err != nil {
|
||||
oldDeviceID = FSMisc
|
||||
}
|
||||
|
||||
// remove existing config directory (if it exists and not in append mode)
|
||||
if !preserveOldConfigDir {
|
||||
isAccessible, _ := back.TargetIsFile(ConfigDir, false) // error is ignored because dir/file status is irrelevant
|
||||
if isAccessible {
|
||||
err = os.RemoveAll(ConfigDir)
|
||||
if err != nil {
|
||||
return "", errors.New("unable to remove existing config directory: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create config directory w/devices subdirectory
|
||||
err = os.MkdirAll(ConfigDir+PathSeparator+"devices", 0700)
|
||||
if err != nil {
|
||||
return "", errors.New("unable to create \"" + ConfigDir + "\": " + err.Error())
|
||||
}
|
||||
|
||||
return oldDeviceID, nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package core
|
||||
package global
|
||||
|
||||
// TargetLocationFormat returns the full location of an entry (given the name) formatted for the current platform.
|
||||
func TargetLocationFormat(targetLocationIncomplete string) string {
|
||||
@@ -1,8 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package core
|
||||
package global
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TargetLocationFormat returns the full location of an entry (given the name) formatted for the current platform.
|
||||
func TargetLocationFormat(targetLocationIncomplete string) string {
|
||||
@@ -1,17 +1,28 @@
|
||||
module github.com/rwinkhart/libmutton
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727
|
||||
github.com/pkg/sftp v1.13.7
|
||||
github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481
|
||||
golang.org/x/crypto v0.34.0
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rwinkhart/go-boilerplate v0.1.0
|
||||
github.com/rwinkhart/rcw v0.2.2
|
||||
golang.design/x/clipboard v0.7.1 // only for mobile builds
|
||||
golang.org/x/crypto v0.43.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
github.com/rwinkhart/peercred-mini v0.1.1 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db // indirect; only for mobile builds
|
||||
golang.org/x/image v0.32.0 // indirect; only for mobile builds
|
||||
golang.org/x/mobile v0.0.0-20251021151156-188f512ec823 // indirect; only for mobile builds
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
)
|
||||
|
||||
replace golang.org/x/sys => github.com/rwinkhart/sys v0.37.0
|
||||
|
||||
replace github.com/Microsoft/go-winio => github.com/rwinkhart/go-winio-easy-pipe-handles v0.0.0-20250407031321-96994a0e8410
|
||||
|
||||
@@ -1,72 +1,44 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
|
||||
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fortis/go-steam-totp v0.0.0-20171114202746-18e928674727 h1:1RkPJqfzrncAuh9xgoslr9OplZskm+VRA9lkucHPQZ4=
|
||||
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.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM=
|
||||
github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
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=
|
||||
github.com/pquerna/otp v1.4.1-0.20231130234153-3357de7c0481/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rwinkhart/go-boilerplate v0.1.0 h1:EzlVj6R7Bxtl79Nl7R5zRcg6s+Cf2FAqGIzR4giWTQg=
|
||||
github.com/rwinkhart/go-boilerplate v0.1.0/go.mod h1:cnzIF45I0FCOvE4YIB+26pLCUx2kWyY2llKYZruNaRY=
|
||||
github.com/rwinkhart/go-winio-easy-pipe-handles v0.0.0-20250407031321-96994a0e8410 h1:NhHwFM3Pgm6zRUfFKvi0p5ndjfFbVWsRwmmhyFlG4PE=
|
||||
github.com/rwinkhart/go-winio-easy-pipe-handles v0.0.0-20250407031321-96994a0e8410/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/rwinkhart/peercred-mini v0.1.1 h1:hDoqSEwynJN4w8OWlZNSQAjAum/ocP3AYF7DjtPV5qU=
|
||||
github.com/rwinkhart/peercred-mini v0.1.1/go.mod h1:dstv+IydIklCnffwAZgD2AWqkGe0mETn3lLtqJjYAeI=
|
||||
github.com/rwinkhart/rcw v0.2.2 h1:bTUi3BLjrcoibi5YDlzeiVKo608EmGetymXbJVM3oYE=
|
||||
github.com/rwinkhart/rcw v0.2.2/go.mod h1:lhTErVEG3klKVJkcoHuTw5pDHiS3Irlj7Z03IshHh7M=
|
||||
github.com/rwinkhart/sys v0.37.0 h1:fX7Zv1ndDoUI4Mi2ABy/sqlxNRYQALVWvYSXglPRVBc=
|
||||
github.com/rwinkhart/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
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.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.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.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.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.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=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c=
|
||||
golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db h1:NmsmaSkEAq6A8r0Q78WxD0IygJNCs6J3uYDgNuPkXYM=
|
||||
golang.org/x/exp/shiny v0.0.0-20251017212417-90e834f514db/go.mod h1:QMAAUorQ8fzCK0C6mr4X4XV9BEp7Al6+jlejJvfYKw4=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/mobile v0.0.0-20251021151156-188f512ec823 h1:M0DtBf/UvJoTH+tk6tgHT2NVxNEJCYhVu1g/xeD+GEk=
|
||||
golang.org/x/mobile v0.0.0-20251021151156-188f512ec823/go.mod h1:3QSlP0AtP6HPTLbsxfgfefGN76jpIB9yBsMqB8UY37I=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+33
-17
@@ -7,8 +7,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
"github.com/rwinkhart/libmutton/sync"
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/go-boilerplate/other"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
"github.com/rwinkhart/libmutton/syncserver"
|
||||
)
|
||||
|
||||
const ansiBold = "\033[1m"
|
||||
@@ -36,38 +39,51 @@ func main() {
|
||||
case "fetch":
|
||||
// print all information needed for syncing to stdout for interpretation by the client
|
||||
// stdin[0] is expected to be the device ID
|
||||
sync.GetRemoteDataFromServer(stdin[0])
|
||||
syncserver.GetRemoteDataFromServer(stdin[0])
|
||||
case "rename":
|
||||
// move an entry to a new location before using fallthrough to add its previous iteration to the deletions directory
|
||||
// stdin[0] is evaluated after fallthrough
|
||||
// stdin[1] is expected to be the OLD incomplete target location with FSPath representing path separators - Always pass in UNIX format
|
||||
// stdin[2] is expected to be the NEW incomplete target location with FSPath representing path separators - Always pass in UNIX format
|
||||
sync.RenameLocal(strings.ReplaceAll(stdin[1], core.FSPath, "/"), strings.ReplaceAll(stdin[2], core.FSPath, "/"), true)
|
||||
_ = synccommon.RenameLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), strings.ReplaceAll(stdin[2], global.FSPath, "/"))
|
||||
fallthrough // fallthrough to add the old entry to the deletions directory
|
||||
case "shear":
|
||||
// shear an entry from the server and add it to the deletions directory
|
||||
// stdin[0] is expected to be the device ID
|
||||
// stdin[1] is expected to be the incomplete target location with FSPath representing path separators - Always pass in UNIX format
|
||||
sync.ShearLocal(strings.ReplaceAll(stdin[1], core.FSPath, "/"), stdin[0])
|
||||
_, _, _ = synccommon.ShearLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), stdin[0])
|
||||
case "addfolder":
|
||||
// add a new folder to the server
|
||||
// stdin[0] is expected to be the incomplete target location with FSPath representing path separators - Always pass in UNIX format
|
||||
sync.AddFolderLocal(strings.ReplaceAll(stdin[0], core.FSPath, "/"))
|
||||
_ = synccommon.AddFolderLocal(strings.ReplaceAll(stdin[0], global.FSPath, "/"))
|
||||
case "register":
|
||||
// register a new device ID
|
||||
// stdin[0] is expected to be the device ID
|
||||
// stdin[1] is expected to be the old device ID (for removal)
|
||||
fileToClose, _ := os.OpenFile(core.ConfigDir+core.PathSeparator+"devices"+core.PathSeparator+stdin[0], os.O_CREATE|os.O_WRONLY, 0600) // errors ignored; failure unlikely to occur if init was successful; "register" is not a user-facing argument and thus the error would not be visible
|
||||
fileToClose, _ := os.OpenFile(global.ConfigDir+global.PathSeparator+"devices"+global.PathSeparator+stdin[0], os.O_CREATE|os.O_WRONLY, 0600) // errors ignored; failure unlikely to occur if init was successful; "register" is not a user-facing argument and thus the error would not be visible
|
||||
_ = fileToClose.Close()
|
||||
if stdin[1] != core.FSMisc { // sync.FSMisc is used to indicate that no device ID is being replaced
|
||||
_ = os.RemoveAll(core.ConfigDir + core.PathSeparator + "devices" + core.PathSeparator + stdin[1])
|
||||
if stdin[1] != global.FSMisc { // FSMisc is used to indicate that no device ID is being replaced
|
||||
// remove the old device ID file
|
||||
_ = os.RemoveAll(global.ConfigDir + global.PathSeparator + "devices" + global.PathSeparator + stdin[1])
|
||||
// carry over deletions from the old device ID to the new one
|
||||
deletionsDirRoot := global.ConfigDir + global.PathSeparator + "deletions" + global.PathSeparator
|
||||
deletionsList, _ := os.ReadDir(deletionsDirRoot)
|
||||
for _, deletion := range deletionsList {
|
||||
affectedIDTargetLocationIncomplete := strings.Split(deletion.Name(), global.FSSpace)
|
||||
if affectedIDTargetLocationIncomplete[0] == stdin[1] {
|
||||
_ = os.Rename(deletionsDirRoot+deletion.Name(), deletionsDirRoot+stdin[0]+global.FSSpace+affectedIDTargetLocationIncomplete[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
// print EntryRoot and bool indicating OS type to stdout for client to store in config
|
||||
fmt.Print(core.EntryRoot + core.FSSpace + strconv.FormatBool(core.IsWindows))
|
||||
fmt.Print(global.EntryRoot + global.FSSpace + strconv.FormatBool(global.IsWindows))
|
||||
case "init":
|
||||
// create the necessary directories for libmuttonserver to function
|
||||
core.DirInit(false)
|
||||
_ = os.MkdirAll(core.ConfigDir+core.PathSeparator+"deletions", 0700) // error ignored; failure would have occurred by this point in core.DirInit
|
||||
_, err := global.DirInit(false)
|
||||
if err != nil {
|
||||
other.PrintError("Failed to initialize libmuttonserver directories: "+err.Error(), back.ErrorWrite)
|
||||
}
|
||||
_ = os.MkdirAll(global.ConfigDir+global.PathSeparator+"deletions", 0700) // error ignored; failure would have occurred by this point in core.DirInit
|
||||
fmt.Println("libmuttonserver directories initialized")
|
||||
case "version":
|
||||
versionServer()
|
||||
@@ -77,13 +93,13 @@ func main() {
|
||||
}
|
||||
|
||||
func helpServer() {
|
||||
fmt.Print(ansiBold + "\nlibmuttonserver | Copyright (c) 2024-2025 Randall Winkhart\n" + core.AnsiReset + `
|
||||
fmt.Print(ansiBold + "\nlibmuttonserver | Copyright (c) 2024-2025 Randall Winkhart\n" + back.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.
|
||||
|
||||
` + ansiBold + "Usage:" + core.AnsiReset + ` libmuttonserver <argument>
|
||||
` + ansiBold + "Usage:" + back.AnsiReset + ` libmuttonserver <argument>
|
||||
|
||||
` + ansiBold + "Arguments (user):" + core.AnsiReset + `
|
||||
` + ansiBold + "Arguments (user):" + back.AnsiReset + `
|
||||
help Bring up this menu
|
||||
version Display version and license information
|
||||
init Create the necessary directories for libmuttonserver to function` + "\n\n")
|
||||
@@ -91,7 +107,7 @@ This program comes with absolutely no warranty; type "libmuttonserver version" f
|
||||
}
|
||||
|
||||
func versionServer() {
|
||||
fmt.Print(ansiBold + "\n MIT License" + core.AnsiReset + `
|
||||
fmt.Print(ansiBold + "\n MIT License" + back.AnsiReset + `
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
@@ -116,5 +132,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 + "\n\n Copyright (c) 2024-2025: Randall Winkhart" + "\n\n")
|
||||
fmt.Print(ansiBold + "\n\n libmuttonserver" + back.AnsiReset + " Version " + global.LibmuttonVersion + "\n\n Copyright (c) 2024-2025: Randall Winkhart" + "\n\n")
|
||||
}
|
||||
|
||||
+10
-8
@@ -1,13 +1,15 @@
|
||||
#!/bin/sh
|
||||
# This script generates portable libmuttonserver release binaries for the following platforms:
|
||||
# - Linux (x86_64_v1)
|
||||
# - Linux (aarch64)
|
||||
# - Windows (x86_64_v1)
|
||||
# - Windows (aarch64)
|
||||
# - Linux (x86_64_v2)
|
||||
# - Linux (arm64v8.0)
|
||||
# - Linux (arm64v8.7)
|
||||
# - Windows (x86_64_v2)
|
||||
# - Windows (arm64v8.7)
|
||||
|
||||
mkdir -p ./1output
|
||||
cd ..
|
||||
GOOS=linux CGO_ENABLED=0 GOAMD64=v1 go build -o ./packaging/1output/libmuttonserver-linux-x86_64_v1 -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-linux-aarch64 -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=windows CGO_ENABLED=0 GOAMD64=v1 go build -o ./packaging/1output/libmuttonserver-windows-x86_64_v1.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-windows-aarch64.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=linux CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/libmuttonserver-linux-x86_64_v2 -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=linux GOARCH=arm64 GOARM64=v8.0 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-linux-arm64v8.0 -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=linux GOARCH=arm64 GOARM64=v8.7 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-linux-arm64v8.7 -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=windows CGO_ENABLED=0 GOAMD64=v2 go build -o ./packaging/1output/libmuttonserver-windows-x86_64_v2.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
GOOS=windows GOARCH=arm64 GOARM64=v8.7 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-windows-arm64v8.7.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package sync
|
||||
|
||||
import "github.com/rwinkhart/libmutton/core"
|
||||
|
||||
var rootLength = len(core.EntryRoot) // length of core.EntryRoot string
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
)
|
||||
|
||||
// getModTimes returns a list of all entry modification times.
|
||||
func getModTimes(entryList []string) []int64 {
|
||||
var modList []int64
|
||||
for _, file := range entryList {
|
||||
modTime, _ := os.Stat(core.TargetLocationFormat(file))
|
||||
modList = append(modList, modTime.ModTime().Unix())
|
||||
}
|
||||
|
||||
return modList
|
||||
}
|
||||
|
||||
// ShearLocal removes the target file or directory from the local system.
|
||||
// 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, bool) {
|
||||
// determine if running on a server
|
||||
var onServer bool
|
||||
if clientDeviceID != "" {
|
||||
onServer = true
|
||||
}
|
||||
|
||||
deviceIDList := core.GenDeviceIDList(true)
|
||||
|
||||
// add the sheared target (incomplete, vanity) to the deletions list (if running on a server)
|
||||
if onServer {
|
||||
for _, device := range *deviceIDList {
|
||||
if device.Name() != clientDeviceID {
|
||||
fileToClose, err := os.OpenFile(core.ConfigDir+core.PathSeparator+"deletions"+core.PathSeparator+device.Name()+core.FSSpace+strings.ReplaceAll(targetLocationIncomplete, "/", core.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
// do not print error as there is currently no way of seeing server-side errors
|
||||
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
|
||||
os.Exit(core.ErrorWrite)
|
||||
}
|
||||
_ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
isFile, _ = core.TargetIsFile(targetLocationComplete, true, 0)
|
||||
}
|
||||
err := os.RemoveAll(targetLocationComplete)
|
||||
if err != nil {
|
||||
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(), !isFile
|
||||
}
|
||||
return "", true
|
||||
|
||||
// do not exit program, as this function is used as part of ShearRemoteFromClient
|
||||
}
|
||||
|
||||
// RenameLocal renames oldLocationIncomplete to newLocationIncomplete on the local system.
|
||||
// This function should only be used directly by the server binary.
|
||||
func RenameLocal(oldLocationIncomplete, newLocationIncomplete string, verifyOldLocationExists bool) {
|
||||
// get full paths for both locations
|
||||
oldLocation := core.TargetLocationFormat(oldLocationIncomplete)
|
||||
newLocation := core.TargetLocationFormat(newLocationIncomplete)
|
||||
|
||||
if verifyOldLocationExists {
|
||||
core.TargetIsFile(oldLocation, true, 0)
|
||||
}
|
||||
|
||||
// ensure newLocation does not exist
|
||||
_, isAccessible := core.TargetIsFile(newLocation, false, 0)
|
||||
if isAccessible {
|
||||
core.PrintError("\""+newLocation+"\" already exists", core.ErrorTargetExists, true)
|
||||
}
|
||||
|
||||
// rename oldLocation to newLocation
|
||||
err := os.Rename(oldLocation, newLocation)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// AddFolderLocal creates a new entry-containing directory on the local system.
|
||||
// This function should only be used directly by the server binary.
|
||||
func AddFolderLocal(targetLocationIncomplete string) {
|
||||
// get the full targetLocation path and create the target
|
||||
targetLocationComplete := core.TargetLocationFormat(targetLocationIncomplete)
|
||||
err := os.Mkdir(targetLocationComplete, 0700)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
core.PrintError("Directory already exists", core.ErrorTargetExists, true)
|
||||
} else {
|
||||
core.PrintError("Failed to create directory: "+err.Error(), core.ErrorWrite, true)
|
||||
}
|
||||
}
|
||||
|
||||
// do not exit program, as this function is used as part of AddFolderRemoteFromClient
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
)
|
||||
|
||||
// DeviceIDGen generates a new client device ID and registers it with the server (will replace existing one).
|
||||
// Device IDs are only needed for online synchronization.
|
||||
// Device IDs are guaranteed unique as the current UNIX time is appended to them.
|
||||
// Returns: the remote EntryRoot and OS type indicator.
|
||||
func DeviceIDGen(oldDeviceID string) (string, string) {
|
||||
// generate new device ID
|
||||
deviceIDPrefix, _ := os.Hostname()
|
||||
deviceIDSuffix := core.StringGen(rand.Intn(32)+48, true, 0.2, true) + "-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
newDeviceID := deviceIDPrefix + "-" + deviceIDSuffix
|
||||
|
||||
// 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 {
|
||||
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
|
||||
|
||||
// register new device ID with server and fetch remote EntryRoot and OS type
|
||||
// also removes the old device ID file (remotely)
|
||||
// manualSync is true so the user is alerted if device ID registration fails
|
||||
sshClient, _, _ := GetSSHClient(true)
|
||||
sshEntryRootSSHIsWindows := strings.Split(GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID), core.FSSpace)
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
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 {
|
||||
core.PrintError("Failed to remove old device ID file (locally): "+err.Error(), core.ErrorWrite, true)
|
||||
}
|
||||
|
||||
return sshEntryRootSSHIsWindows[0], sshEntryRootSSHIsWindows[1]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
)
|
||||
|
||||
// ShearRemoteFromClient removes the target file or directory from the local system and calls the server to remove it remotely and add it to the deletions list.
|
||||
// It can safely be called in offline mode, as well, so this is the intended interface for shearing (ShearLocal should only be used directly by the server binary).
|
||||
func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
|
||||
deviceID, isDir := ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
|
||||
|
||||
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 {
|
||||
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
|
||||
}
|
||||
}
|
||||
|
||||
core.Exit(0) // sync is not required after shearing since the target has already been removed from the local system
|
||||
}
|
||||
|
||||
// RenameRemoteFromClient renames oldLocationIncomplete to newLocationIncomplete on the local system and calls the server to perform the rename remotely and add the old target to the deletions list.
|
||||
// It can safely be called in offline mode, as well, so this is the intended interface for renaming (RenameLocal should only be used directly by the server binary).
|
||||
func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, forceOffline bool) {
|
||||
RenameLocal(oldLocationIncomplete, newLocationIncomplete, false) // move the target on the local system
|
||||
|
||||
deviceIDList := core.GenDeviceIDList(true)
|
||||
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)
|
||||
|
||||
// call the server to move the target on the remote system and add the old target to the deletions list
|
||||
GetSSHOutput(sshClient, "libmuttonserver rename",
|
||||
(*deviceIDList)[0].Name()+"\n"+
|
||||
strings.ReplaceAll(oldLocationIncomplete, core.PathSeparator, core.FSPath)+"\n"+
|
||||
strings.ReplaceAll(newLocationIncomplete, core.PathSeparator, core.FSPath))
|
||||
|
||||
// close the SSH client
|
||||
err := sshClient.Close()
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
|
||||
}
|
||||
}
|
||||
|
||||
core.Exit(0)
|
||||
}
|
||||
|
||||
// AddFolderRemoteFromClient creates a new entry-containing directory on the local system and calls the server to create the folder remotely.
|
||||
// It can safely be called in offline mode, as well, so this is the intended interface for adding folders (AddFolderLocal should only be used directly by the server binary).
|
||||
func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
|
||||
AddFolderLocal(targetLocationIncomplete) // add the folder on the local system
|
||||
|
||||
deviceIDList := core.GenDeviceIDList(true)
|
||||
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)
|
||||
|
||||
// call the server to create the folder remotely
|
||||
GetSSHOutput(sshClient, "libmuttonserver addfolder", strings.ReplaceAll(targetLocationIncomplete, core.PathSeparator, core.FSPath)) // call the server to create the folder remotely
|
||||
|
||||
// close the SSH client
|
||||
err := sshClient.Close()
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
|
||||
}
|
||||
}
|
||||
|
||||
core.Exit(0)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package sync
|
||||
package syncclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -8,52 +9,52 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/cfg"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// ANSI color constants used only in this file
|
||||
const (
|
||||
ansiDelete = "\033[38;5;1m"
|
||||
ansiDownload = "\033[38;5;2m"
|
||||
ansiUpload = "\033[38;5;4m"
|
||||
)
|
||||
|
||||
// GetSSHClient returns an SSH client connection to the server (also returns the remote EntryRoot and an indicator of the server's OS).
|
||||
// Only supports key-based authentication (passphrases are supported for CLI-based implementations).
|
||||
func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
|
||||
// get SSH config info, exit if not configured (displaying an error if the sync job was called manually)
|
||||
var sshUserConfig []string
|
||||
var missingValueError string
|
||||
if manualSync {
|
||||
missingValueError = "SSH settings not fully configured"
|
||||
} else {
|
||||
missingValueError = "0" // allow silent exit at this point in offline mode
|
||||
// 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 (passwords are supported for CLI-based implementations).
|
||||
func GetSSHClient() (*ssh.Client, bool, bool, string, error) {
|
||||
// get SSH config info
|
||||
sshUserConfig, err := cfg.ParseConfig([][2]string{{"LIBMUTTON", "offlineMode"}, {"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}})
|
||||
if len(sshUserConfig) == 1 {
|
||||
// offline mode is enabled
|
||||
return nil, true, false, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to parse SSH config: " + err.Error())
|
||||
}
|
||||
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
|
||||
var err error
|
||||
for i, key := range sshUserConfig {
|
||||
switch i {
|
||||
case 0:
|
||||
user = key
|
||||
case 1:
|
||||
ip = key
|
||||
user = key
|
||||
case 2:
|
||||
port = key
|
||||
ip = key
|
||||
case 3:
|
||||
keyFile = key
|
||||
port = key
|
||||
case 4:
|
||||
keyFileProtected = key
|
||||
keyFile = key
|
||||
case 5:
|
||||
entryRoot = key
|
||||
keyFileProtected = key
|
||||
case 6:
|
||||
entryRoot = key
|
||||
case 7:
|
||||
isWindows, err = strconv.ParseBool(key)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to parse server OS type: "+err.Error(), core.ErrorRead, true)
|
||||
return nil, false, false, "", errors.New("unable to parse server OS type: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +62,7 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
|
||||
// read private key
|
||||
key, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to read private key: "+keyFile, core.ErrorRead, true)
|
||||
return nil, false, false, "", errors.New("unable to read private key: " + keyFile)
|
||||
}
|
||||
|
||||
// parse private key
|
||||
@@ -69,17 +70,17 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
|
||||
if keyFileProtected != "true" {
|
||||
parsedKey, err = ssh.ParsePrivateKey(key)
|
||||
} else {
|
||||
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, core.PassphraseInputFunction("Enter passphrase for your SSH keyfile:"))
|
||||
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, global.GetPassword("Enter password for your SSH keyfile:"))
|
||||
}
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to parse private key: "+keyFile, core.ErrorRead, true)
|
||||
return nil, false, false, "", errors.New("unable to parse private key: " + keyFile)
|
||||
}
|
||||
|
||||
// read known hosts file
|
||||
var hostKeyCallback ssh.HostKeyCallback
|
||||
hostKeyCallback, err = knownhosts.New(core.Home + core.PathSeparator + ".ssh" + core.PathSeparator + "known_hosts")
|
||||
hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts")
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to read known hosts file: "+err.Error(), core.ErrorRead, true)
|
||||
return nil, false, false, "", errors.New("unable to read known hosts file: " + err.Error())
|
||||
}
|
||||
|
||||
// configure SSH client
|
||||
@@ -95,19 +96,18 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) {
|
||||
// connect to SSH server
|
||||
sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig)
|
||||
if err != nil {
|
||||
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 nil, false, false, "", errors.New("unable to connect to remote server: " + err.Error())
|
||||
}
|
||||
|
||||
return sshClient, entryRoot, isWindows
|
||||
return sshClient, false, isWindows, entryRoot, nil
|
||||
}
|
||||
|
||||
// GetSSHOutput runs a command over SSH and returns the output as a string.
|
||||
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
|
||||
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) (string, error) {
|
||||
// create a session
|
||||
sshSession, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to establish SSH session: "+err.Error(), core.ErrorServerConnection, true)
|
||||
return "", errors.New("unable to establish SSH session: " + err.Error())
|
||||
}
|
||||
|
||||
// provide stdin data for session
|
||||
@@ -117,45 +117,47 @@ func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
|
||||
var output []byte
|
||||
output, err = sshSession.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to run SSH command: "+err.Error(), core.ErrorSyncProcess, true)
|
||||
return "", errors.New("unable to run SSH command: " + err.Error())
|
||||
}
|
||||
|
||||
// convert the output to a string and remove leading/trailing whitespace
|
||||
outputString := string(output)
|
||||
outputString = strings.TrimSpace(outputString)
|
||||
|
||||
return outputString
|
||||
return outputString, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func getRemoteDataFromClient(sshClient *ssh.Client) (map[string]int64, []string, []string, int64, int64, error) {
|
||||
// get remote output over SSH
|
||||
deviceIDList := core.GenDeviceIDList(true)
|
||||
if len(*deviceIDList) == 0 {
|
||||
if manualSync {
|
||||
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
|
||||
}
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, err
|
||||
}
|
||||
if len(deviceIDList) == 0 {
|
||||
return nil, nil, nil, 0, 0, errors.New("no device ID found")
|
||||
}
|
||||
clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time
|
||||
output := GetSSHOutput(sshClient, "libmuttonserver fetch", (*deviceIDList)[0].Name())
|
||||
output, err := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name())
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to run remote command: " + err.Error())
|
||||
}
|
||||
|
||||
// split output into slice based on occurrences of FSSpace
|
||||
outputSlice := strings.Split(output, core.FSSpace)
|
||||
outputSlice := strings.Split(output, global.FSSpace)
|
||||
|
||||
// parse output/re-form lists
|
||||
if len(outputSlice) != 5 { // ensure information from server is complete
|
||||
core.PrintError("Sync failed - Unable to fetch remote data; server returned an unexpected response", core.ErrorSyncProcess, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to run remote command; server returned an unexpected response")
|
||||
}
|
||||
serverTime, err := strconv.ParseInt(outputSlice[0], 10, 64)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to parse server time: "+err.Error(), core.ErrorRead, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to parse server time: " + err.Error())
|
||||
}
|
||||
entries := strings.Split(outputSlice[1], core.FSMisc)[1:]
|
||||
modsStrings := strings.Split(outputSlice[2], core.FSMisc)[1:]
|
||||
folders := strings.Split(outputSlice[3], core.FSMisc)[1:]
|
||||
deletions := strings.Split(outputSlice[4], core.FSMisc)[1:]
|
||||
entries := strings.Split(outputSlice[1], global.FSMisc)[1:]
|
||||
modsStrings := strings.Split(outputSlice[2], global.FSMisc)[1:]
|
||||
folders := strings.Split(outputSlice[3], global.FSMisc)[1:]
|
||||
deletions := strings.Split(outputSlice[4], global.FSMisc)[1:]
|
||||
|
||||
// convert the mod times to int64
|
||||
var mods []int64
|
||||
@@ -163,7 +165,7 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
|
||||
for _, modString := range modsStrings {
|
||||
mod, err = strconv.ParseInt(modString, 10, 64)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to parse mod time: "+err.Error(), core.ErrorRead, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to parse mod time: " + err.Error())
|
||||
}
|
||||
mods = append(mods, mod)
|
||||
}
|
||||
@@ -174,16 +176,19 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
|
||||
entryModMap[entry] = mods[i]
|
||||
}
|
||||
|
||||
return entryModMap, folders, deletions, serverTime, clientTime
|
||||
return entryModMap, folders, deletions, serverTime, clientTime, nil
|
||||
}
|
||||
|
||||
// getLocalData returns a map of local entries to their modification times.
|
||||
func getLocalData() map[string]int64 {
|
||||
func getLocalData() (map[string]int64, error) {
|
||||
// get a list of all entries
|
||||
entries, _ := WalkEntryDir()
|
||||
entries, _, err := synccommon.WalkEntryDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get a list of all entry modification times
|
||||
modList := getModTimes(entries)
|
||||
modList := synccommon.GetModTimes(entries)
|
||||
|
||||
// map the entries to their modification times
|
||||
entryModMap := make(map[string]int64)
|
||||
@@ -192,7 +197,7 @@ func getLocalData() map[string]int64 {
|
||||
}
|
||||
|
||||
// return the lists
|
||||
return entryModMap
|
||||
return entryModMap, nil
|
||||
}
|
||||
|
||||
// targetLocationFormatSFTP formats the target location to match the remote server's entry directory and path separator.
|
||||
@@ -205,17 +210,14 @@ func targetLocationFormatSFTP(targetName, serverEntryRoot string, serverIsWindow
|
||||
}
|
||||
|
||||
// sftpSync takes two slices of entries (one for downloads and one for uploads) and syncs them between the client and server using SFTP.
|
||||
func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, downloadList, uploadList []string) {
|
||||
func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, downloadList, uploadList []string) error {
|
||||
// create an SFTP client from sshClient
|
||||
sftpClient, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to establish SFTP session: "+err.Error(), core.ErrorServerConnection, true)
|
||||
return errors.New("unable to establish SFTP session: " + err.Error())
|
||||
}
|
||||
defer func(sftpClient *sftp.Client) {
|
||||
err = sftpClient.Close()
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to close SFTP client: "+err.Error(), core.ErrorServerConnection, true)
|
||||
}
|
||||
_ = sftpClient.Close()
|
||||
}(sftpClient)
|
||||
|
||||
// iterate over the download list
|
||||
@@ -223,7 +225,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
for _, entryName := range downloadList {
|
||||
filesTransferred = true // set a flag to indicate that files have been downloaded (used to determine whether to print a gap between download and upload messages)
|
||||
|
||||
fmt.Println("Downloading " + ansiDownload + entryName + core.AnsiReset)
|
||||
fmt.Println("Downloading " + synccommon.AnsiDownload + entryName + back.AnsiReset)
|
||||
|
||||
// store path to remote entry
|
||||
remoteEntryFullPath := targetLocationFormatSFTP(entryName, sshEntryRoot, sshIsWindows)
|
||||
@@ -232,7 +234,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var fileInfo os.FileInfo
|
||||
fileInfo, err = sftpClient.Stat(remoteEntryFullPath)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to get remote file info (mod time): "+err.Error(), core.ErrorRead, true)
|
||||
return errors.New("unable to get remote file info (mod time): " + err.Error())
|
||||
}
|
||||
modTime := fileInfo.ModTime()
|
||||
|
||||
@@ -240,23 +242,23 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var remoteFile *sftp.File
|
||||
remoteFile, err = sftpClient.Open(remoteEntryFullPath)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to open remote file: "+err.Error(), core.ErrorRead, true)
|
||||
return errors.New("unable to open remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// store path to local entry
|
||||
localEntryFullPath := core.TargetLocationFormat(entryName)
|
||||
localEntryFullPath := global.TargetLocationFormat(entryName)
|
||||
|
||||
// create local file
|
||||
var localFile *os.File
|
||||
localFile, err = os.OpenFile(localEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to create local file: "+err.Error(), core.ErrorWrite, true)
|
||||
return errors.New("unable to create local file: " + err.Error())
|
||||
}
|
||||
|
||||
// download the file
|
||||
_, err = remoteFile.WriteTo(localFile)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to download remote file: "+err.Error(), core.ErrorSyncProcess, true)
|
||||
return errors.New("unable to download remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// close the files
|
||||
@@ -265,6 +267,9 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
|
||||
// set the modification time of the local file to match the value saved from the remote file (from before the download)
|
||||
err = os.Chtimes(localEntryFullPath, time.Now(), modTime)
|
||||
if err != nil {
|
||||
return errors.New("unable to set local file modification time: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if filesTransferred {
|
||||
@@ -276,16 +281,16 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
for _, entryName := range uploadList {
|
||||
filesTransferred = true // set a flag to indicate that files have been uploaded (used to determine whether to print a gap between upload and sync complete messages)
|
||||
|
||||
fmt.Println("Uploading " + ansiUpload + entryName + core.AnsiReset)
|
||||
fmt.Println("Uploading " + synccommon.AnsiUpload + entryName + back.AnsiReset)
|
||||
|
||||
// store path to local entry
|
||||
localEntryFullPath := core.TargetLocationFormat(entryName)
|
||||
localEntryFullPath := global.TargetLocationFormat(entryName)
|
||||
|
||||
// save modification time of local file
|
||||
var fileInfo os.FileInfo
|
||||
fileInfo, err = os.Stat(localEntryFullPath)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to get local file info (mod time): "+err.Error(), core.ErrorRead, true)
|
||||
return errors.New("unable to get local file info (mod time): " + err.Error())
|
||||
}
|
||||
modTime := fileInfo.ModTime()
|
||||
|
||||
@@ -293,7 +298,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var localFile *os.File
|
||||
localFile, err = os.Open(localEntryFullPath)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to open local file: "+err.Error(), core.ErrorRead, true)
|
||||
return errors.New("unable to open local file: " + err.Error())
|
||||
}
|
||||
|
||||
// store path to remote entry
|
||||
@@ -303,13 +308,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 {
|
||||
core.PrintError("Sync failed - Unable to create remote file ("+remoteEntryFullPath+"): "+err.Error(), core.ErrorWrite, true)
|
||||
return errors.New("unable to create remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// upload the file
|
||||
_, err = localFile.WriteTo(remoteFile)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to upload local file: "+err.Error(), core.ErrorSyncProcess, true)
|
||||
return errors.New("unable to upload local file: " + err.Error())
|
||||
}
|
||||
|
||||
// close the files
|
||||
@@ -319,21 +324,26 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
// set permissions on remote file
|
||||
err = sftpClient.Chmod(remoteEntryFullPath, 0600)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to set permissions on remote file: "+err.Error(), core.ErrorSyncProcess, true)
|
||||
return errors.New("unable to set permissions on remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// set the modification time of the remote file to match the value saved from the local file (from before the upload)
|
||||
err = sftpClient.Chtimes(remoteEntryFullPath, time.Now(), modTime)
|
||||
if err != nil {
|
||||
return errors.New("unable to set remote file modification time: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if filesTransferred {
|
||||
fmt.Println() // add a gap between upload and sync complete messages
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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, returnLists bool, localEntryModMap, remoteEntryModMap map[string]int64) [3][]string {
|
||||
func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSynced, returnLists bool, localEntryModMap, remoteEntryModMap map[string]int64) ([3][]string, error) {
|
||||
// initialize slices to store entries that need to be downloaded or uploaded
|
||||
var downloadList, uploadList []string
|
||||
|
||||
@@ -343,124 +353,145 @@ func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSyn
|
||||
if remoteModTime, present := remoteEntryModMap[entry]; present {
|
||||
// entry exists on both client and server, compare mod times
|
||||
if remoteModTime > localModTime {
|
||||
fmt.Println(ansiDownload+entry+core.AnsiReset, "is newer on server, adding to download list")
|
||||
fmt.Println(synccommon.AnsiDownload+entry+back.AnsiReset, "is newer on server, adding to download list")
|
||||
downloadList = append(downloadList, entry)
|
||||
} else if remoteModTime < localModTime {
|
||||
fmt.Println(ansiUpload+entry+core.AnsiReset, "is newer on client, adding to upload list")
|
||||
fmt.Println(synccommon.AnsiUpload+entry+back.AnsiReset, "is newer on client, adding to upload list")
|
||||
uploadList = append(uploadList, entry)
|
||||
}
|
||||
// remove entry from remoteEntryModMap (process of elimination)
|
||||
delete(remoteEntryModMap, entry)
|
||||
} else {
|
||||
fmt.Println(ansiUpload+entry+core.AnsiReset, "does not exist on server, adding to upload list")
|
||||
fmt.Println(synccommon.AnsiUpload+entry+back.AnsiReset, "does not exist on server, adding to upload list")
|
||||
uploadList = append(uploadList, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// iterate over remaining entries in remoteEntryModMap
|
||||
for entry := range remoteEntryModMap {
|
||||
fmt.Println(ansiDownload+entry+core.AnsiReset, "does not exist on client, adding to download list")
|
||||
fmt.Println(synccommon.AnsiDownload+entry+back.AnsiReset, "does not exist on client, adding to download list")
|
||||
downloadList = append(downloadList, entry)
|
||||
}
|
||||
|
||||
// call sftpSync with the download and upload lists
|
||||
if timeSynced && (max(len(downloadList), len(uploadList)) > 0) { // only call sftpSync if there are entries to download or upload
|
||||
fmt.Println() // add a gap between list-add messages and the actual sync messages from sftpSync
|
||||
sftpSync(sshClient, sshEntryRoot, sshIsWindows, downloadList, uploadList)
|
||||
err := sftpSync(sshClient, sshEntryRoot, sshIsWindows, downloadList, uploadList)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
|
||||
}
|
||||
} else if !timeSynced {
|
||||
// do not call sftpSync if the client and server times are out of sync
|
||||
core.Exit(1)
|
||||
back.Exit(global.ErrorSyncProcess)
|
||||
}
|
||||
|
||||
fmt.Println("Client is synchronized with server")
|
||||
|
||||
if returnLists {
|
||||
return [3][]string{nil, downloadList, uploadList}
|
||||
return [3][]string{nil, downloadList, uploadList}, nil
|
||||
}
|
||||
return [3][]string{nil, nil, nil}
|
||||
return [3][]string{nil, nil, nil}, nil
|
||||
}
|
||||
|
||||
// deletionSync removes entries from the client that have been deleted on the server (multi-client deletion).
|
||||
func deletionSync(deletions []string) {
|
||||
func deletionSync(deletions []string) error {
|
||||
var filesDeleted bool
|
||||
for _, deletion := range deletions {
|
||||
filesDeleted = true // set a flag to indicate that files have been deleted (used to determine whether to print a gap between deletion and other messages)
|
||||
fmt.Println(ansiDelete+deletion+core.AnsiReset, "has been sheared, removing locally (if it exists)")
|
||||
err := os.RemoveAll(core.TargetLocationFormat(deletion))
|
||||
fmt.Println(synccommon.AnsiDelete+deletion+back.AnsiReset, "has been sheared, removing locally (if it exists)")
|
||||
err := os.RemoveAll(global.TargetLocationFormat(deletion))
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Failed to shear "+deletion+" locally: "+err.Error(), core.ErrorWrite, true)
|
||||
return errors.New("unable to shear " + deletion + " locally: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if filesDeleted {
|
||||
fmt.Println() // add a gap between deletion and other messages
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// folderSync creates folders on the client (from the given list of folder names).
|
||||
func folderSync(folders []string) {
|
||||
func folderSync(folders []string) error {
|
||||
for _, folder := range folders {
|
||||
// store the full local path of the folder
|
||||
folderFullPath := core.TargetLocationFormat(folder)
|
||||
folderFullPath := global.TargetLocationFormat(folder)
|
||||
|
||||
// check if folder already exists
|
||||
isFile, isAccessible := core.TargetIsFile(folderFullPath, false, 1)
|
||||
// check if target path already exists
|
||||
isAccessible, err := back.TargetIsFile(folderFullPath, false)
|
||||
|
||||
if !isFile && !isAccessible {
|
||||
if !isAccessible {
|
||||
err := os.MkdirAll(folderFullPath, 0700)
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Failed to create folder ("+folder+"): "+err.Error(), core.ErrorWrite, true)
|
||||
return errors.New("unable to create folder (" + folder + "): " + err.Error())
|
||||
}
|
||||
} else if isFile {
|
||||
core.PrintError("Sync failed - Failed to create folder ("+folder+") - A file with the same name already exists", core.ErrorTargetExists, true)
|
||||
} else if err != nil {
|
||||
return errors.New("unable to create folder (" + folder + "): " + err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunJob runs the SSH sync job.
|
||||
// Setting manualSync to true will throw errors if sync is not configured (online mode is assumed).
|
||||
// Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client.
|
||||
func RunJob(manualSync, returnLists bool) [3][]string {
|
||||
func RunJob(returnLists bool) ([3][]string, error) {
|
||||
// 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}
|
||||
sshClient, offlineMode, sshIsWindows, sshEntryRoot, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
return [3][]string{nil, nil, nil}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
defer func(sshClient *ssh.Client) {
|
||||
err := sshClient.Close()
|
||||
if err != nil {
|
||||
core.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), core.ErrorServerConnection, true)
|
||||
}
|
||||
_ = sshClient.Close()
|
||||
}(sshClient)
|
||||
|
||||
// fetch remote lists
|
||||
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime := getRemoteDataFromClient(sshClient, manualSync)
|
||||
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to fetch remote data: " + err.Error())
|
||||
}
|
||||
|
||||
// sync deletions
|
||||
deletionSync(deletions)
|
||||
err = deletionSync(deletions)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync deletions: " + err.Error())
|
||||
}
|
||||
|
||||
// sync folders
|
||||
folderSync(remoteFolders)
|
||||
err = folderSync(remoteFolders)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync folders: " + err.Error())
|
||||
}
|
||||
|
||||
// fetch local lists
|
||||
localEntryModMap := getLocalData()
|
||||
localEntryModMap, err := getLocalData()
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to fetch local entry data: " + err.Error())
|
||||
}
|
||||
|
||||
// prior to syncing lists, ensure the client and server clocks are synced within 45 seconds
|
||||
// before syncing lists, ensure the client and server clocks are synced within 45 seconds
|
||||
var timeSynced = true
|
||||
timeDiff := serverTime - clientTime
|
||||
if timeDiff < -45 || timeDiff > 45 {
|
||||
timeSynced = false
|
||||
fmt.Print(core.AnsiError + "Client and server clocks are out of sync.\n\nPlease ensure both clocks are correct before attempting to sync again.\n\nA dry sync output will be printed below (if any operations would have been performed). It is strongly recommended to review it and manually update the modification times as applicable to ensure the correct version of each entry is kept.\n\nIf the client's clock is at fault, update the modification times of any entries pending upload, even if the correct (upload) operation is being performed on them. Failure to do so could result in entries being uploaded to the server with the incorrect modification times (could result in data loss).\n\n" + core.AnsiReset)
|
||||
fmt.Print(back.AnsiError + "Client and server clocks are out of sync.\n\nPlease ensure both clocks are correct before attempting to sync again.\n\nA dry sync output will be printed below (if any operations would have been performed). It is strongly recommended to review it and manually update the modification times as applicable to ensure the correct version of each entry is kept.\n\nIf the client's clock is at fault, update the modification times of any entries pending upload, even if the correct (upload) operation is being performed on them. Failure to do so could result in entries being uploaded to the server with the incorrect modification times (could result in data loss).\n\n" + back.AnsiReset)
|
||||
}
|
||||
|
||||
// sync new and updated entries
|
||||
var lists [3][]string
|
||||
if returnLists {
|
||||
lists = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap)
|
||||
lists, err = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
|
||||
}
|
||||
lists[0] = deletions
|
||||
return lists
|
||||
return lists, nil
|
||||
}
|
||||
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
|
||||
_, err = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
|
||||
}
|
||||
back.Exit(0) // exit program if running non-interactively
|
||||
return lists, nil // dummy return for when not returning lists
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package syncclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
)
|
||||
|
||||
// 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) error {
|
||||
deviceID, isDir, err := synccommon.ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
|
||||
if err != nil {
|
||||
return errors.New("unable to shear target locally: " + err.Error())
|
||||
}
|
||||
|
||||
sshClient, offlineMode, _, _, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
goto end
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
if deviceID == "" {
|
||||
return errors.New("unable to shear target remotely: no device ID found")
|
||||
}
|
||||
|
||||
// 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
|
||||
_, err = GetSSHOutput(sshClient, "libmuttonserver shear", deviceID+"\n"+strings.ReplaceAll(targetLocationIncomplete, global.PathSeparator, global.FSPath))
|
||||
if err != nil {
|
||||
return errors.New("unable to shear target remotely: " + err.Error())
|
||||
}
|
||||
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
end:
|
||||
back.Exit(0) // sync is not required after shearing since the target has already been removed from the local system
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameRemoteFromClient renames oldLocationIncomplete to newLocationIncomplete on
|
||||
// the local system and calls the server to perform the rename remotely and add the
|
||||
// old target to the deletions list.
|
||||
// It can safely be called in offline mode, as well, so this is the intended
|
||||
// interface for renaming (RenameLocal should only be used directly by the server binary).
|
||||
func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string) error {
|
||||
err := synccommon.RenameLocal(oldLocationIncomplete, newLocationIncomplete) // move the target on the local system
|
||||
if err != nil {
|
||||
return errors.New("unable to rename target locally: " + err.Error())
|
||||
}
|
||||
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return errors.New("unable to generate device ID list: " + err.Error())
|
||||
}
|
||||
// create an SSH client
|
||||
sshClient, offlineMode, _, _, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
goto end
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
if deviceIDList[0].Name() == "" {
|
||||
return errors.New("unable to rename target remotely: no device ID found")
|
||||
}
|
||||
|
||||
// call the server to move the target on the remote system and add the old target to the deletions list
|
||||
_, err = GetSSHOutput(sshClient, "libmuttonserver rename",
|
||||
(deviceIDList)[0].Name()+"\n"+
|
||||
strings.ReplaceAll(oldLocationIncomplete, global.PathSeparator, global.FSPath)+"\n"+
|
||||
strings.ReplaceAll(newLocationIncomplete, global.PathSeparator, global.FSPath))
|
||||
if err != nil {
|
||||
return errors.New("unable to rename target remotely: " + err.Error())
|
||||
}
|
||||
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
end:
|
||||
back.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddFolderRemoteFromClient creates a new entry-containing directory
|
||||
// on the local system and calls the server to create the folder remotely.
|
||||
// It can safely be called in offline mode, as well, so this is the
|
||||
// intended interface for adding folders (AddFolderLocal should only be
|
||||
// used directly by the server binary).
|
||||
func AddFolderRemoteFromClient(targetLocationIncomplete string) error {
|
||||
err := synccommon.AddFolderLocal(targetLocationIncomplete) // add the folder on the local system
|
||||
if err != nil {
|
||||
return errors.New("unable to add folder locally: " + err.Error())
|
||||
}
|
||||
|
||||
// create an SSH client
|
||||
sshClient, offlineMode, _, _, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
goto end
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
// call the server to create the folder remotely
|
||||
_, err = GetSSHOutput(sshClient, "libmuttonserver addfolder", strings.ReplaceAll(targetLocationIncomplete, global.PathSeparator, global.FSPath)) // call the server to create the folder remotely
|
||||
if err != nil {
|
||||
return errors.New("unable to add folder remotely: " + err.Error())
|
||||
}
|
||||
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
end:
|
||||
back.Exit(0)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package synccommon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
// ANSI color constants used only in this file
|
||||
const (
|
||||
AnsiDelete = "\033[38;5;1m"
|
||||
AnsiDownload = "\033[38;5;2m"
|
||||
AnsiUpload = "\033[38;5;4m"
|
||||
)
|
||||
|
||||
var RootLength = len(global.EntryRoot) // length of global.EntryRoot string
|
||||
|
||||
// GetModTimes returns a list of all entry modification times.
|
||||
func GetModTimes(entryList []string) []int64 {
|
||||
var modList []int64
|
||||
for _, file := range entryList {
|
||||
modTime, _ := os.Stat(global.TargetLocationFormat(file))
|
||||
modList = append(modList, modTime.ModTime().Unix())
|
||||
}
|
||||
|
||||
return modList
|
||||
}
|
||||
|
||||
// ShearLocal removes the target file or directory from the local system.
|
||||
// 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, bool, error) {
|
||||
// determine if running on a server
|
||||
var onServer bool
|
||||
if clientDeviceID != "" {
|
||||
onServer = true
|
||||
}
|
||||
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return "", false, errors.New("unable to generate device ID list: " + err.Error())
|
||||
}
|
||||
|
||||
// add the sheared target (incomplete, vanity) to the deletions list (if running on a server)
|
||||
if onServer {
|
||||
for _, device := range deviceIDList {
|
||||
if device.Name() != clientDeviceID {
|
||||
fileToClose, err := os.OpenFile(global.ConfigDir+global.PathSeparator+"deletions"+global.PathSeparator+device.Name()+global.FSSpace+strings.ReplaceAll(targetLocationIncomplete, "/", global.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
// do not print error as there is currently no way of seeing server-side errors
|
||||
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
|
||||
os.Exit(back.ErrorWrite)
|
||||
}
|
||||
_ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove the target locally
|
||||
targetLocationComplete := global.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
|
||||
isAccessible, err := back.TargetIsFile(targetLocationComplete, true)
|
||||
if !isAccessible {
|
||||
return "", false, err
|
||||
}
|
||||
if err == nil { // fails if target is a directory, so no error indicates a file
|
||||
isFile = true
|
||||
}
|
||||
}
|
||||
err = os.RemoveAll(targetLocationComplete)
|
||||
if err != nil {
|
||||
return "", false, errors.New("unable to remove local target: " + err.Error())
|
||||
}
|
||||
|
||||
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(), !isFile, nil
|
||||
}
|
||||
return "", true, nil
|
||||
|
||||
// do not exit program, as this function is used as part of ShearRemoteFromClient
|
||||
}
|
||||
|
||||
// RenameLocal renames oldLocationIncomplete to newLocationIncomplete on the local system.
|
||||
// This function should only be used directly by the server binary.
|
||||
func RenameLocal(oldLocationIncomplete, newLocationIncomplete string) error {
|
||||
// get full paths for both locations
|
||||
oldLocation := global.TargetLocationFormat(oldLocationIncomplete)
|
||||
newLocation := global.TargetLocationFormat(newLocationIncomplete)
|
||||
|
||||
// ensure newLocation does not exist
|
||||
isAccessible, _ := back.TargetIsFile(newLocation, true) // error is ignored because dir/file status is irrelevant
|
||||
if isAccessible {
|
||||
return errors.New("new target (" + newLocation + ") already exists")
|
||||
}
|
||||
|
||||
// rename oldLocation to newLocation
|
||||
err := os.Rename(oldLocation, newLocation)
|
||||
if err != nil {
|
||||
return errors.New("unable to rename: " + err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
// do not exit program, as this function is used as part of RenameRemoteFromClient
|
||||
}
|
||||
|
||||
// AddFolderLocal creates a new entry-containing directory on the local system.
|
||||
// This function should only be used directly by the server binary.
|
||||
func AddFolderLocal(targetLocationIncomplete string) error {
|
||||
// create the target locally
|
||||
targetLocationComplete := global.TargetLocationFormat(targetLocationIncomplete)
|
||||
err := os.Mkdir(targetLocationComplete, 0700)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
fmt.Println(AnsiUpload + "Directory already exists - libmutton will still ensure it exists on the server")
|
||||
} else {
|
||||
return errors.New("unable to create directory: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
// do not exit program, as this function is used as part of AddFolderRemoteFromClient
|
||||
}
|
||||
@@ -1,37 +1,38 @@
|
||||
//go:build !windows
|
||||
|
||||
package sync
|
||||
package synccommon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
// WalkEntryDir walks the entry directory and returns lists of all files and directories found (two separate lists).
|
||||
// Regardless of platform, all paths are stored with forward slashes (UNIX-style).
|
||||
func WalkEntryDir() ([]string, []string) {
|
||||
func WalkEntryDir() ([]string, []string, error) {
|
||||
// define file/directory containing slices so that they may be accessed by the anonymous WalkDir function
|
||||
var fileList []string
|
||||
var dirList []string
|
||||
|
||||
// walk entry directory
|
||||
_ = filepath.WalkDir(core.EntryRoot,
|
||||
err := filepath.WalkDir(global.EntryRoot,
|
||||
func(fullPath string, entry fs.DirEntry, err error) error {
|
||||
|
||||
// check for errors encountered while walking directory
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
core.PrintError("The entry directory does not exist - Initialize libmutton to create it", core.ErrorOther, true)
|
||||
return errors.New("entry directory does not exist; initialize libmutton to create it")
|
||||
} else {
|
||||
core.PrintError("An unexpected error occurred while generating the entry list: "+err.Error(), core.ErrorOther, true)
|
||||
return errors.New("an unexpected error occurred while generating the entry list: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// trim root path from each path before storing
|
||||
trimmedPath := fullPath[rootLength:]
|
||||
trimmedPath := fullPath[RootLength:]
|
||||
|
||||
// append the path to the appropriate slice
|
||||
if !entry.IsDir() {
|
||||
@@ -42,6 +43,8 @@ func WalkEntryDir() ([]string, []string) {
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return fileList, dirList
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileList, dirList, nil
|
||||
}
|
||||
@@ -1,38 +1,39 @@
|
||||
//go:build windows
|
||||
|
||||
package sync
|
||||
package synccommon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
// WalkEntryDir walks the entry directory and returns lists of all files and directories found (two separate lists).
|
||||
// Regardless of platform, all paths are stored with forward slashes (UNIX-style).
|
||||
func WalkEntryDir() ([]string, []string) {
|
||||
func WalkEntryDir() ([]string, []string, error) {
|
||||
// define file/directory containing slices so that they may be accessed by the anonymous WalkDir function
|
||||
var fileList []string
|
||||
var dirList []string
|
||||
|
||||
// walk entry directory
|
||||
_ = filepath.WalkDir(core.EntryRoot,
|
||||
err := filepath.WalkDir(global.EntryRoot,
|
||||
func(fullPath string, entry fs.DirEntry, err error) error {
|
||||
|
||||
// check for errors encountered while walking directory
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
core.PrintError("The entry directory does not exist - Initialize libmutton to create it", core.ErrorOther, true)
|
||||
return errors.New("entry directory does not exist; initialize libmutton to create it")
|
||||
} else {
|
||||
core.PrintError("An unexpected error occurred while generating the entry list: "+err.Error(), core.ErrorOther, true)
|
||||
return errors.New("an unexpected error occurred while generating the entry list: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// trim root path from each path before storing and replace backslashes with forward slashes
|
||||
trimmedPath := strings.ReplaceAll(fullPath[rootLength:], "\\", "/")
|
||||
trimmedPath := strings.ReplaceAll(fullPath[RootLength:], "\\", "/")
|
||||
|
||||
// append the path to the appropriate slice
|
||||
if !entry.IsDir() {
|
||||
@@ -43,6 +44,8 @@ func WalkEntryDir() ([]string, []string) {
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return fileList, dirList
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileList, dirList, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package synccycles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/syncclient"
|
||||
)
|
||||
|
||||
// DeviceIDGen generates a new client device ID and registers it with the server (will replace existing one).
|
||||
// Device IDs are only needed for online synchronization.
|
||||
// Device IDs are guaranteed unique as the current UNIX time is appended to them.
|
||||
// Leave prefix empty to use the current hostname as the prefix.
|
||||
// Returns: the remote EntryRoot and OS type indicator.
|
||||
func DeviceIDGen(oldDeviceID, prefix string) (string, string, error) {
|
||||
// generate new device ID
|
||||
if prefix == "" {
|
||||
prefix, _ = os.Hostname()
|
||||
}
|
||||
newDeviceID := prefix + "-" + StringGen(rand.Intn(32)+48, 0.2, 1) + "-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
// create new device ID file (locally)
|
||||
newDeviceIDPath := global.ConfigDir + global.PathSeparator + "devices" + global.PathSeparator + newDeviceID
|
||||
oldDeviceIDPath := global.ConfigDir + global.PathSeparator + "devices" + global.PathSeparator + oldDeviceID
|
||||
f, err := os.OpenFile(newDeviceIDPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return "", "", errors.New("unable to create local device ID file: " + err.Error())
|
||||
}
|
||||
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
|
||||
cleanupOnFail := func() {
|
||||
// remove new device ID file
|
||||
_ = os.RemoveAll(newDeviceIDPath)
|
||||
if oldDeviceID != global.FSMisc {
|
||||
// restore old device ID file (if it existed and has already been removed due to DirInit)
|
||||
if isAccessible, _ := back.TargetIsFile(oldDeviceIDPath, true); !isAccessible {
|
||||
f, _ := os.OpenFile(oldDeviceIDPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// register new device ID with server and fetch remote EntryRoot and OS type
|
||||
// also removes the old device ID file (remotely)
|
||||
// if registration fails, remove the new device ID file locally and return before removing the old one
|
||||
sshClient, _, _, _, err := syncclient.GetSSHClient()
|
||||
if err != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
output, err := syncclient.GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID)
|
||||
if err != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", errors.New("unable to register device ID with server: " + err.Error())
|
||||
}
|
||||
sshEntryRootSSHIsWindows := strings.Split(output, global.FSSpace)
|
||||
_ = sshClient.Close() // ignore error; non-critical/unlikely/not much could be done about it
|
||||
|
||||
// remove old device ID file (locally; may not exist)
|
||||
err = os.RemoveAll(oldDeviceIDPath)
|
||||
if err != nil {
|
||||
return "", "", errors.New("unable to remove old device ID file (locally): " + err.Error())
|
||||
}
|
||||
|
||||
return sshEntryRootSSHIsWindows[0], sshEntryRootSSHIsWindows[1], nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package synccycles
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StringGen generates a random string of a specified length and complexity.
|
||||
// Requires: complexity (minimum percentage of special characters to be returned in the generated string; set to 0 to generate a simple string),
|
||||
// complexCharsetLevel (1 = safe for filenames, 2 = safe for most password entries, 3 = safe only for well-made password entries)
|
||||
func StringGen(length int, complexity float64, complexCharsetLevel uint8) string {
|
||||
var actualSpecialChars int // track the number of special characters in the generated string
|
||||
var minSpecialChars int // track the minimum number of special characters to accept
|
||||
var extendedCharset string // additions to character set used for complex strings
|
||||
|
||||
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // default character set used for all strings
|
||||
const extendedCharsetFiles = "!#$%&+,-.;=@_~^()[]{}`'" // additional special characters for complex strings (safe in file names)
|
||||
const extendedCharsetMostPassword = "*:><?|" // additional special characters for complex strings (NOT safe in file names)
|
||||
const extendedCharsetSpecialPassword = "\"/\\" // additional special characters for complex strings (NOT safe in file names)
|
||||
|
||||
if complexity > 0 {
|
||||
minSpecialChars = int(math.Round(float64(length) * complexity)) // determine minimum number of special characters to accept
|
||||
switch complexCharsetLevel {
|
||||
case 1:
|
||||
extendedCharset = extendedCharsetFiles
|
||||
case 2:
|
||||
extendedCharset = extendedCharsetMostPassword + extendedCharsetFiles[:len(extendedCharsetFiles)-9]
|
||||
case 3:
|
||||
extendedCharset = extendedCharsetFiles + extendedCharsetMostPassword + extendedCharsetSpecialPassword
|
||||
}
|
||||
charset += extendedCharset
|
||||
}
|
||||
|
||||
// loop until a string of the desired complexity is generated
|
||||
for {
|
||||
// generate a random string
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
val, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
result[i] = charset[val.Int64()]
|
||||
}
|
||||
|
||||
// return early if the string is not complex
|
||||
if complexity <= 0 {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// count the number of special characters in the generated string
|
||||
for _, char := range string(result) {
|
||||
if strings.ContainsRune(extendedCharset, char) {
|
||||
actualSpecialChars++
|
||||
}
|
||||
}
|
||||
|
||||
// return the generated string if it contains enough special characters
|
||||
if actualSpecialChars >= minSpecialChars {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// reset special character counter
|
||||
fmt.Println("Regenerating string until desired complexity is achieved...")
|
||||
actualSpecialChars = 0
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package sync
|
||||
package syncserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,53 +6,51 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
)
|
||||
|
||||
// GetRemoteDataFromServer prints to stdout the remote entries, mod times, folders, and deletions.
|
||||
// Lists in output are separated by FSSpace.
|
||||
// Output is meant to be captured over SSH for interpretation by the client.
|
||||
func GetRemoteDataFromServer(clientDeviceID string) {
|
||||
entryList, dirList := WalkEntryDir()
|
||||
modList := getModTimes(entryList)
|
||||
deletionsList, err := os.ReadDir(core.ConfigDir + core.PathSeparator + "deletions")
|
||||
if err != nil {
|
||||
core.PrintError("Failed to read the deletions directory: "+err.Error(), core.ErrorRead, true)
|
||||
}
|
||||
entryList, dirList, _ := synccommon.WalkEntryDir()
|
||||
modList := synccommon.GetModTimes(entryList)
|
||||
deletionsList, _ := os.ReadDir(global.ConfigDir + global.PathSeparator + "deletions")
|
||||
|
||||
// print the current UNIX timestamp to stdout
|
||||
fmt.Print(time.Now().Unix())
|
||||
|
||||
// print the lists to stdout
|
||||
// entry list
|
||||
fmt.Print(core.FSSpace)
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, entry := range entryList {
|
||||
fmt.Print(core.FSMisc + entry)
|
||||
fmt.Print(global.FSMisc + entry)
|
||||
}
|
||||
|
||||
// modification time list
|
||||
fmt.Print(core.FSSpace)
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, mod := range modList {
|
||||
fmt.Print(core.FSMisc)
|
||||
fmt.Print(global.FSMisc)
|
||||
fmt.Print(mod)
|
||||
}
|
||||
|
||||
// directory/folder list
|
||||
fmt.Print(core.FSSpace)
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, dir := range dirList {
|
||||
fmt.Print(core.FSMisc + dir)
|
||||
fmt.Print(global.FSMisc + dir)
|
||||
}
|
||||
|
||||
// deletions list
|
||||
fmt.Print(core.FSSpace)
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, deletion := range deletionsList {
|
||||
// print deletion if it is relevant to the current client device
|
||||
affectedIDTargetLocationIncomplete := strings.Split(deletion.Name(), core.FSSpace)
|
||||
affectedIDTargetLocationIncomplete := strings.Split(deletion.Name(), global.FSSpace)
|
||||
if affectedIDTargetLocationIncomplete[0] == clientDeviceID {
|
||||
fmt.Print(core.FSMisc + strings.ReplaceAll(affectedIDTargetLocationIncomplete[1], core.FSPath, "/"))
|
||||
fmt.Print(global.FSMisc + strings.ReplaceAll(affectedIDTargetLocationIncomplete[1], global.FSPath, "/"))
|
||||
|
||||
// assume successful client deletion and remove deletions file (if assumption is somehow false, worst case scenario is that the client will re-upload the deleted entry)
|
||||
_ = os.Remove(core.ConfigDir + core.PathSeparator + "deletions" + core.PathSeparator + deletion.Name()) // error ignored; function not run from a user-facing argument and thus the error would not be visible
|
||||
_ = os.Remove(global.ConfigDir + global.PathSeparator + "deletions" + global.PathSeparator + deletion.Name()) // error ignored; function not run from a user-facing argument and thus the error would not be visible
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -1,10 +1,7 @@
|
||||
## Planned Breaking Changes
|
||||
Leading up to the v1.0.0 release, breaking changes are both expected and planned. These changes are expected to require manual intervention from the end-user, and thus MUTN/libmutton should not be used prior to v1.0.0 if this is not acceptable.
|
||||
Leading up to the v1.0.0 release, breaking changes are both expected and planned. These changes are expected to require manual intervention from the end-user, and thus MUTN/libmutton should not be used prior to v1.0.0 if this is not acceptable.
|
||||
|
||||
These changes include, but may expand beyond the following:
|
||||
|
||||
- Migration to Go-native encryption (no reliance on GnuPG)
|
||||
- Will be based on symmetrical encryption
|
||||
- Will eventually allow combining multiple common encryption algorithms (cascading encryption)
|
||||
- Password aging data will be stored for each entry (to remind the user when it is time to change passwords)
|
||||
- Will be included in entry names or in external file (to prevent needing to decrypt entries to access this information)
|
||||
- Will be included in entry names or in external file (to prevent needing to decrypt entries to access this information)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
## 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
|
||||
+1
-1
@@ -8,4 +8,4 @@ Clipboard managers save a history of what has been copied to the clipboard, whic
|
||||
It is likely other popular clipboard managers exhibit this behavior. I noticed it with KDE Klipper, which is what prompted me to create this wiki page. **Clipboard managers should not be enabled by default in any environment** or distribution due to their **potential security implications**.
|
||||
### Termux cannot clear the clipboard from the background
|
||||
***
|
||||
If using libmutton on Termux (Android), the clipboard may not successfully be cleared after the 30 second timeout period if Termux is not actively in the foreground when the sleep timer expires. This is an unfortunate side-effect of running on Android and cannot be easily fixed. Due to Termux being at the bottom of the platform support priority list, I will not be investing time into working around this.
|
||||
If using libmutton on Termux (Android), the clipboard may not successfully be cleared after the 30-second timeout period if Termux is not actively in the foreground when the sleep timer expires. This is an unfortunate side effect of running on Android and cannot be easily fixed. Due to Termux being at the bottom of the platform support priority list, I will not be investing time in working around this.
|
||||
+16
-6
@@ -16,10 +16,12 @@ These are as follows:
|
||||
- `termux`: Allows creating an Android binary that can interact with the Termux clipboard (for Android)
|
||||
|
||||
## 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.
|
||||
- `global.GetPassword` must be set to allow for different types of clients (CLI, GUI, TUI) to prompt for the password in the most appropriate way.
|
||||
- `crypt.Daemonize`, true by default, determines whether to make use of the RCW daemon for password caching. This may be best to disable for interactive clients.
|
||||
|
||||
## 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()`.
|
||||
## Required Arguments
|
||||
- `clipclear`: 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()`.
|
||||
- `startrcwd`: Should be accepted by all libmutton implementations making use of the RCW daemon to cache passwords. Please accept a `startrcwd` argument that calls `core.RCWDArgument()`.
|
||||
|
||||
## Configuration
|
||||
libmutton-based password manager clients should all share the same INI configuration file.
|
||||
@@ -30,7 +32,6 @@ On UNIX-like systems, this is located at `~/.config/libmutton/libmutton.ini`. On
|
||||
The current base layout of `libmutton.ini` will change leading up to release v1.0.0. As of right now, the specification is as follows:
|
||||
```
|
||||
[LIBMUTTON]
|
||||
gpgID = <gpg key id>
|
||||
sshUser = <remote user>
|
||||
sshIP = <remote ip>
|
||||
sshPort = <remote ssh port>
|
||||
@@ -46,5 +47,14 @@ configKey = <value>
|
||||
```
|
||||
This ensures that a user can use multiple client applications with the same configuration while avoiding conflicts.
|
||||
|
||||
# Relevant Bugs Affecting Third-Party Client Implementations
|
||||
- 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
|
||||
## Entry Format
|
||||
A decrypted libmutton entry is a plaintext file where each line indicates a new field in the entry.
|
||||
|
||||
These fields are as follows:
|
||||
```
|
||||
0/first line: password
|
||||
1/second line: username
|
||||
2/third line: TOTP secret
|
||||
3/fourth line: URL
|
||||
4+/fifth line+: notes
|
||||
```
|
||||
|
||||
+6
-3
@@ -1,8 +1,11 @@
|
||||
## Migrating From Other Password Managers
|
||||
**Important Notice**: The libmutton entry format is not final and has two [breaking changes planned prior to release v1.0.0](https://github.com/rwinkhart/libmutton/blob/main/wiki/breaking.md). This guide will be updated accordingly.
|
||||
### pass
|
||||
libmutton-based password managers *currently* use GnuPG encryption and an entry format similar to that of [pass](https://www.passwordstore.org/). Because of this, any entries in `pass` format can simply be dropped into `~/.local/share/libmutton`.
|
||||
#### libmutton < v0.4.0
|
||||
libmutton-based password managers (prior to v0.4.0) use GnuPG encryption and an entry format similar to that of [pass](https://www.passwordstore.org/). Because of this, any entries in `pass` format can simply be dropped into `~/.local/share/libmutton`.
|
||||
#### libmutton >= v0.4.0
|
||||
Current libmutton-based password managers use a custom, embedded cryptography agent ([RCW](https://github.com/rwinkhart/rcw)). The layout of the entries themselves has not changed, so gpg-encrypted entries (from `pass` or older libmutton releases) can simply be decrypted and re-encrypted with RCW. A [conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0) has been published for this purpose.
|
||||
### sshyp
|
||||
sshyp, though also `pass`-compatible, makes some changes to the entry format that take effect once the entry has been imported. The changes made by sshyp are not compatible with libmutton, and as such sshyp entries must be converted before they can be used. A script for doing that has been created and is available in the sshyp extension store. Simply run `sshyp tweak`, go to the "extension management" menu, and download the "export-to-libmutton" extension. After doing this, the `sshyp export` command can be used to export entries in libmutton format.
|
||||
`sshyp`, though also `pass`-compatible, makes some changes to the entry format that take effect once the entry has been imported. The changes made by `sshyp` are not compatible with libmutton, and as such `sshyp` entries must be converted before they can be used. A script for exporting to libmutton (prior to v0.4.0) has been published to the `sshyp` extension store. Simply run `sshyp tweak`, go to the "extension management" menu, and download the "export-to-libmutton" extension. After doing this, the `sshyp export` command can be used to export entries in libmutton format. If migrating into libmutton v0.4.0 or later, a second [conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0) will be needed to re-encrypt the exported `sshyp` entries.
|
||||
### Other
|
||||
The formats for many other password managers can be converted to the `pass` format with community scripts. Some of these scripts are listed [here](https://www.passwordstore.org/#migration). Once converted, entries can be dropped into `~/.local/share/libmutton`.
|
||||
The formats for many other password managers can be converted to the `pass` format with community scripts. Some of these scripts are listed [here](https://www.passwordstore.org/#migration). Once converted, entries can be dropped into `~/.local/share/libmutton`. If migrating into libmutton v0.4.0 or later, a second [conversion program](https://github.com/rwinkhart/sshyp-labs/releases/tag/v2.0.0) will be needed to re-encrypt the converted entries.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
## Miscellaneous Tips
|
||||
### TOTP Support
|
||||
#### Standard 6-digit TOTP
|
||||
For standard TOTP, all the user needs to do is add their TOTP secret (NOT the full URL) string as the value for the TOTP field in an entry.
|
||||
#### Steam TOTP
|
||||
For Steam TOTP, the user must first extract their TOTP secret from the Steam app for Android.
|
||||
|
||||
There are several methods for doing this, but the most consistent I have found is [detailed here](https://github.com/JustArchiNET/ArchiSteamFarm/discussions/2786). Note that a rooted phone is not required if using a ROM like LineageOS that offers rooted ADB (must be enabled in developer options and adb must be started with `adb -d root`).
|
||||
|
||||
This method will result in a Base64-encoded Steam TOTP secret. libmutton requires a base32-encoded secret, so this secret must be converted as follows (on Linux/FreeBSD/Mac): `printf '<shared_secret>' | base64 -d | base32`
|
||||
|
||||
To signal to libmutton that this TOTP secret is for Steam, prepend it with "steam@" when adding it to the TOTP field in an entry, e.g. "steam@bAsE32sEcReTkEy". This will tell libmutton to use the Steam-specific TOTP encoder.
|
||||
Reference in New Issue
Block a user