mirror of
https://github.com/rwinkhart/libmutton.git
synced 2026-08-28 12:56:31 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726153b9be | ||
|
|
e8548dd907 | ||
|
|
768d4f6fbd | ||
|
|
83f59c3cd9 | ||
|
|
e9f49fb98a | ||
|
|
8da2648664 | ||
|
|
6d3e3cdb5b | ||
|
|
b6ebd22463 | ||
|
|
f4fdf65528 | ||
|
|
a091cd4952 | ||
|
|
20d8e141eb | ||
|
|
3fd2b13712 | ||
|
|
f9aeca5ba9 | ||
|
|
273e86d97e | ||
|
|
7ecd143b81 | ||
|
|
9c0ed69d7f | ||
|
|
50cf92df61 | ||
|
|
420333e412 | ||
|
|
25edd344c8 | ||
|
|
5b591e35d8 | ||
|
|
1351360b35 | ||
|
|
02a8196061 | ||
|
|
d7242fee96 | ||
|
|
ec9c6b5062 | ||
|
|
02fc5c1ffa | ||
|
|
e1c6469506 | ||
|
|
cb3a554676 | ||
|
|
b963ccf33e | ||
|
|
409db78d83 | ||
|
|
48e084ac0f | ||
|
|
f82b35bc43 | ||
|
|
2054f5b3cf | ||
|
|
07ad0a1bd9 | ||
|
|
3b4e75b592 | ||
|
|
4467a8c742 | ||
|
|
a54d15e331 | ||
|
|
d0e8640ce6 | ||
|
|
07571a698d | ||
|
|
d21de424cb | ||
|
|
d54b59030e | ||
|
|
ed19d0a416 | ||
|
|
5e5a7c57e9 | ||
|
|
a18c3ea1d6 | ||
|
|
28286788a0 | ||
|
|
571bc0a3bd | ||
|
|
422ca0295a | ||
|
|
b707d67113 | ||
|
|
d8d761b0ca | ||
|
|
bdbdce61aa | ||
|
|
cc626363c7 | ||
|
|
6004a94b77 | ||
|
|
fdce036be6 | ||
|
|
53d82984d9 | ||
|
|
2880ce2823 | ||
|
|
b591e2ef5a | ||
|
|
e2d1b8dc09 | ||
|
|
35db3bf20b | ||
|
|
941a2dc8b8 | ||
|
|
2ab777fd57 | ||
|
|
4e6c18db8a | ||
|
|
7e7fbd15b4 | ||
|
|
01be63c587 | ||
|
|
f16078fe01 | ||
|
|
e355c810da | ||
|
|
ffae2e3002 | ||
|
|
95a877d0b3 | ||
|
|
08bc065785 | ||
|
|
5dc3a5576d | ||
|
|
3ddce4bdf5 | ||
|
|
50d51f9d96 | ||
|
|
d2a6ed5eaa | ||
|
|
04e1b0dd37 | ||
|
|
336dc5c158 | ||
|
|
c63dff4092 | ||
|
|
8d1dd48035 | ||
|
|
80cf65775a | ||
|
|
ac4a86d3a4 | ||
|
|
8aabd01ed2 | ||
|
|
6d1513b469 | ||
|
|
bc793178e1 | ||
|
|
a0f21b31f7 | ||
|
|
a3b89fcb96 | ||
|
|
483af75613 | ||
|
|
b39a40407f | ||
|
|
14dda0b091 | ||
|
|
75ad2afcfb | ||
|
|
9703790336 | ||
|
|
8f4590cf34 | ||
|
|
bf1399ce8d | ||
|
|
d49632d221 | ||
|
|
3506504fb1 | ||
|
|
09501fda7d |
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Randall Winkhart
|
||||
Copyright (c) 2024-2026 Randall Winkhart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -12,11 +12,8 @@ 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.5.0
|
||||
- [ ] Implement "netpin" (quick-unlock)
|
||||
- [ ] Password aging support
|
||||
- [ ] Append UNIX timestamp to entry names
|
||||
#### Release v1.0.0
|
||||
- Stabilize API (reduce need for future breaking changes)
|
||||
- [ ] Create packaging scripts (libmuttonserver)
|
||||
- [ ] Stable source PKGBUILD
|
||||
- [ ] Stable source APKBUILD
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package age
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/crypt"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
)
|
||||
|
||||
// Entry creates/updates the age file for a vanity path.
|
||||
// It does not touch the actual entry, meaning it won't be synced;
|
||||
// this is because this function is meant to be used on entries that
|
||||
// are being created or modified, so they will sync. If using this
|
||||
// to age an entry without modifying the entry, the caller must also
|
||||
// update the mod time on the entry to trigger a sync.
|
||||
func Entry(vanityPath string, timestamp int64) error {
|
||||
ageFilePath := global.AgeDir + global.PathSeparator + strings.ReplaceAll(vanityPath, "/", global.FSPath)
|
||||
f, err := os.OpenFile(ageFilePath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return errors.New("unable to create age file for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
if err = os.Chtimes(ageFilePath, time.Now(), time.Unix(timestamp, 0)); err != nil {
|
||||
return errors.New("unable to set timestamp on age file for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllPasswordEntries adds age data for all un-aged entries containing passwords.
|
||||
// Each entry is aged with a random timestamp from within the last year to prevent
|
||||
// all entries having their passwords expire at the same time.
|
||||
// It also updates the mod time on the actual entry to trigger a sync.
|
||||
// Leave rcwPassword nil to use RCW demonization.
|
||||
func AllPasswordEntries(forceReage bool, rcwPassword []byte) error {
|
||||
allVanityPaths, _, err := synccommon.WalkEntryDir()
|
||||
if err != nil {
|
||||
return errors.New("unable to walk entry directory: " + err.Error())
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
for _, vanityPath := range allVanityPaths {
|
||||
// ensure entry is not already aged (unless forcing re-age)
|
||||
if !forceReage {
|
||||
// ignore error; we only care if we can access the path or not
|
||||
isAccessible, _ := back.TargetIsFile(global.AgeDir+global.PathSeparator+strings.ReplaceAll(vanityPath, "/", global.FSPath), true)
|
||||
if isAccessible {
|
||||
// entry already aged, skip it
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
decSlice, err := crypt.DecryptFileToSlice(global.EntryRoot+vanityPath, rcwPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if decSlice != nil && decSlice[0] != "" {
|
||||
// calculate random UNIX timestamp from within the last 365 days
|
||||
offsetInt, _ := rand.Int(rand.Reader, big.NewInt(31557600))
|
||||
randomOffset := time.Duration(offsetInt.Int64()) * time.Second
|
||||
if err = Entry(vanityPath, now.Add(-randomOffset).Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
// update entry mod time to trigger sync
|
||||
if err = os.Chtimes(global.GetRealPath(vanityPath), now, now); err != nil {
|
||||
return errors.New("unable to update mod time on entry (" + vanityPath + "): " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TranslateAgeTimestamp returns a uint8 value indicating
|
||||
// the interpreted age status of an age timestamp associated
|
||||
// with an entry.
|
||||
// Magic number legend:
|
||||
// 0 -> no age, 1 -> fresh, 2 -> expiring soon (within a month), 3 -> expired
|
||||
func TranslateAgeTimestamp(timestamp *int64) uint8 {
|
||||
if timestamp == nil {
|
||||
return 0
|
||||
}
|
||||
daysOld := time.Since(time.Unix(*timestamp, 0)).Hours() / 24
|
||||
if daysOld >= 365 {
|
||||
return 3 // expired
|
||||
} else if daysOld >= 335 {
|
||||
return 2 // expiring soon
|
||||
}
|
||||
return 1 // fresh
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package cfg
|
||||
|
||||
// setUmask is a dummy function on Windows.
|
||||
func setUmask(umask int) {
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build !android && !ios
|
||||
|
||||
package clip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/crypt"
|
||||
)
|
||||
|
||||
// CopyShortcut (given a path) decrypts an
|
||||
// entry and copies a field to the clipboard.
|
||||
// Leave rcwPassword nil to use RCW demonization.
|
||||
func CopyShortcut(realPath string, field int, rcwPassword []byte) error {
|
||||
// ensure realPath exists and is a file
|
||||
_, err := back.TargetIsFile(realPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// decrypt entry
|
||||
decSlice, err := crypt.DecryptFileToSlice(realPath, rcwPassword)
|
||||
if err != nil {
|
||||
return errors.New("unable to decrypt entry: " + err.Error())
|
||||
}
|
||||
|
||||
// if field exists in entry...
|
||||
if len(decSlice) > field {
|
||||
if decSlice[field] == "" {
|
||||
return errors.New("field is empty")
|
||||
}
|
||||
|
||||
switch field {
|
||||
case 2: // TOTP
|
||||
fmt.Println(back.AnsiWarning + "[Starting]" + back.AnsiReset + " TOTP clipboard refresher")
|
||||
errorChan := make(chan error, 1)
|
||||
go TOTPCopier(decSlice[2], errorChan, nil) // "done" is not needed because the process runs until the program is killed
|
||||
if err = <-errorChan; err != nil { // handle error from first copy
|
||||
return errors.New("error encountered in TOTP refresh process: " + err.Error())
|
||||
}
|
||||
select {} // block indefinitely
|
||||
case 4:
|
||||
decSlice[field] = strings.TrimRight(decSlice[field], " ")
|
||||
fallthrough
|
||||
default:
|
||||
// copy field to clipboard; launch clipboard clearing process
|
||||
if err = CopyBytes(true, []byte(decSlice[field])); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return errors.New("field does not exist in entry")
|
||||
}
|
||||
}
|
||||
|
||||
// ClearArgument reads the assigned clipboard contents from stdin and passes them to clipClearProcess.
|
||||
func ClearArgument() error {
|
||||
assignedContents := back.ReadFromStdin()
|
||||
if assignedContents == nil {
|
||||
os.Exit(0) // use os.Exit directly since this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment)
|
||||
}
|
||||
return ClearProcess(assignedContents)
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
//go:build (!android && !ios) || termux
|
||||
//go:build !android && !ios
|
||||
|
||||
package core
|
||||
package clip
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
// clipClearProcess clears the clipboard after 30 seconds if the clipboard contents have not changed.
|
||||
// ClearProcess 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()
|
||||
func ClearProcess(assignedContents []byte) error {
|
||||
cmdPaste, cmdClear, err := getClipCommands()
|
||||
if err != nil {
|
||||
return errors.New("unable to determine clipboard platform: " + err.Error())
|
||||
}
|
||||
|
||||
clearClipboard := func() error {
|
||||
err := cmdClear.Run()
|
||||
if err != nil {
|
||||
if err := cmdClear.Run(); err != nil {
|
||||
return errors.New("unable to clear clipboard")
|
||||
}
|
||||
back.Exit(0)
|
||||
@@ -25,9 +27,8 @@ func clipClearProcess(assignedContents string) error {
|
||||
}
|
||||
|
||||
// if assignedContents is empty, clear the clipboard immediately and unconditionally
|
||||
if assignedContents == "" {
|
||||
err := clearClipboard()
|
||||
if err != nil {
|
||||
if assignedContents == nil {
|
||||
if err := clearClipboard(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -40,10 +41,8 @@ func clipClearProcess(assignedContents string) error {
|
||||
if err != nil {
|
||||
return errors.New("unable to read clipboard contents")
|
||||
}
|
||||
|
||||
if assignedContents == strings.TrimRight(string(newContents), "\r\n") {
|
||||
err := clearClipboard()
|
||||
if err != nil {
|
||||
if bytes.Equal(assignedContents, bytes.TrimRight(newContents, "\r\n")) {
|
||||
if err = clearClipboard(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package clip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/go-boilerplate/security"
|
||||
)
|
||||
|
||||
// CopyBytes copies a byte slice to the clipboard.
|
||||
func CopyBytes(clearClipboardAutomatically bool, copySubject []byte) error {
|
||||
cmd := exec.Command("pbcopy")
|
||||
_ = back.WriteToStdin(cmd, copySubject, false)
|
||||
if err := cmd.Run(); err != nil {
|
||||
security.ZeroizeBytes(copySubject)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
if clearClipboardAutomatically {
|
||||
LaunchClearProcess(copySubject)
|
||||
} else {
|
||||
security.ZeroizeBytes(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
|
||||
func getClipCommands() (*exec.Cmd, *exec.Cmd, error) {
|
||||
cmdClear := exec.Command("pbcopy")
|
||||
_ = back.WriteToStdin(cmdClear, nil, false)
|
||||
return exec.Command("pbpaste"), cmdClear, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build !windows && !darwin && !android && !ios && !wsl
|
||||
|
||||
package clip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/go-boilerplate/security"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
// CopyBytes copies a byte slice to the clipboard.
|
||||
func CopyBytes(clearClipboardAutomatically bool, copySubject []byte) error {
|
||||
// determine whether to use wl-copy (Wayland) or xclip (X11)
|
||||
sessionIsWayland, err := isWayland()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cmdCopy *exec.Cmd
|
||||
if sessionIsWayland {
|
||||
cmdCopy = exec.Command("wl-copy", "-t", "text/plain")
|
||||
} else {
|
||||
cmdCopy = exec.Command("xclip", "-sel", "c", "-t", "text/plain")
|
||||
}
|
||||
cmdCopy.SysProcAttr = global.GetSysProcAttr()
|
||||
|
||||
_ = back.WriteToStdin(cmdCopy, copySubject, false)
|
||||
if err = cmdCopy.Run(); err != nil {
|
||||
security.ZeroizeBytes(copySubject)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
if clearClipboardAutomatically {
|
||||
LaunchClearProcess(copySubject)
|
||||
} else {
|
||||
security.ZeroizeBytes(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
|
||||
func getClipCommands() (*exec.Cmd, *exec.Cmd, error) {
|
||||
sessionIsWayland, err := isWayland()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if sessionIsWayland {
|
||||
return exec.Command("wl-paste"), exec.Command("wl-copy", "-c"), nil
|
||||
}
|
||||
return exec.Command("xclip", "-o", "-sel", "c"), exec.Command("xclip", "-i", "/dev/null", "-sel", "c"), nil
|
||||
}
|
||||
|
||||
// isWayland returns a boolean indicating whether the current session is Wayland.
|
||||
func isWayland() (bool, error) {
|
||||
var envSet bool // track whether environment variables are set
|
||||
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
|
||||
return true, nil
|
||||
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.New("unable to detect Wayland or X11 session")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build windows || (linux && wsl)
|
||||
|
||||
package clip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/go-boilerplate/security"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// CopyBytes copies a byte slice to the clipboard.
|
||||
func CopyBytes(clearClipboardAutomatically bool, copySubject []byte) error {
|
||||
cmd := exec.Command("clip.exe")
|
||||
_ = back.WriteToStdin(cmd, copySubject, false)
|
||||
if err := cmd.Run(); err != nil {
|
||||
security.ZeroizeBytes(copySubject)
|
||||
return errors.New("unable to copy to clipboard: " + err.Error())
|
||||
}
|
||||
if clearClipboardAutomatically {
|
||||
LaunchClearProcess(copySubject)
|
||||
} else {
|
||||
security.ZeroizeBytes(copySubject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClipCommands returns the commands for pasting and clearing the clipboard contents.
|
||||
func getClipCommands() (*exec.Cmd, *exec.Cmd, error) {
|
||||
sysProcAttr := &syscall.SysProcAttr{CreationFlags: windows.CREATE_NO_WINDOW}
|
||||
pasteCMD := exec.Command("powershell.exe", "-c", "Get-Clipboard")
|
||||
pasteCMD.SysProcAttr = sysProcAttr
|
||||
clearCMD := exec.Command("clip.exe")
|
||||
clearCMD.SysProcAttr = sysProcAttr
|
||||
_ = back.WriteToStdin(clearCMD, nil, false)
|
||||
return pasteCMD, clearCMD, nil
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
//go:build (windows || darwin || android || ios || termux || wsl) && !interactive
|
||||
//go:build !ios && !android && !interactive
|
||||
|
||||
package core
|
||||
package clip
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
// LaunchClipClearProcess launches the timed clipboard clearing process.
|
||||
// LaunchClearProcess launches the timed clipboard clearing process.
|
||||
// For non-interactive CLI implementations, an entirely separate process is created for this purpose.
|
||||
func LaunchClipClearProcess(copySubject string) {
|
||||
func LaunchClearProcess(copySubject []byte) {
|
||||
cmd := exec.Command(os.Args[0], "clipclear")
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
cmd.SysProcAttr = global.GetSysProcAttr()
|
||||
_ = back.WriteToStdin(cmd, copySubject, true)
|
||||
_ = cmd.Start()
|
||||
os.Exit(0) // use os.Exit directly since this version of this function is only meant for non-interactive CLI implementations
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !ios && !android && interactive
|
||||
|
||||
package clip
|
||||
|
||||
// LaunchClearProcess launches the timed clipboard clearing process.
|
||||
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
|
||||
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
|
||||
func LaunchClearProcess(copySubject []byte) {
|
||||
go ClearProcess(copySubject)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build !android && !ios
|
||||
|
||||
package clip
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/libmutton/core"
|
||||
)
|
||||
|
||||
// TOTPCopier is meant to be run as a goroutine to keep
|
||||
// the clipboard up-to-date with the latest TOTP token.
|
||||
//
|
||||
// Note that errorChan should be buffered with capacity 1.
|
||||
// This is because TOTPCopier only returns errors on the first
|
||||
// iteration, as subsequent errors are highly unlikely to occur
|
||||
// and allowing the caller to move on from error-checking is beneficial.
|
||||
func TOTPCopier(secret string, errorChan chan<- error, done <-chan bool) {
|
||||
var firstRun = true
|
||||
var currentTime time.Time
|
||||
var token string
|
||||
var err error
|
||||
for {
|
||||
currentTime = time.Now()
|
||||
token, err = core.GenTOTP(secret, currentTime)
|
||||
if firstRun && err != nil {
|
||||
errorChan <- err
|
||||
}
|
||||
err = CopyBytes(false, []byte(token))
|
||||
if firstRun {
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
} else {
|
||||
errorChan <- nil // indicate that first copy was successful
|
||||
}
|
||||
firstRun = false
|
||||
}
|
||||
|
||||
// sleep till next 30-second interval
|
||||
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
|
||||
|
||||
// exit after sleep if indicated (will not update clipboard again)
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
type CfgT struct {
|
||||
Libmutton struct {
|
||||
OfflineMode *bool `json:"offlineMode"`
|
||||
SSHUser *string `json:"sshUser"`
|
||||
SSHIP *string `json:"sshIP"`
|
||||
SSHPort *string `json:"sshPort"`
|
||||
SSHEntryRootPath *string `json:"sshEntryRootPath"`
|
||||
SSHAgeDirPath *string `json:"sshAgeDirPath"`
|
||||
SSHKeyPath *string `json:"sshKeyPath"`
|
||||
SSHKeyProtected *bool `json:"sshKeyProtected"`
|
||||
SSHIsWindows *bool `json:"sshIsWindows"`
|
||||
} `json:"libmutton"`
|
||||
ClientSpecific *map[string]any `json:"clientSpecific"`
|
||||
}
|
||||
|
||||
// Load loads libmuttoncfg.json and returns the configuration.
|
||||
func Load() (*CfgT, error) {
|
||||
cfgBytes, err := os.ReadFile(global.CfgPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to load libmuttoncfg.json: " + err.Error())
|
||||
}
|
||||
var cfg CfgT
|
||||
if err = json.Unmarshal(cfgBytes, &cfg); err != nil {
|
||||
return nil, errors.New("unable to unmarshal libmuttoncfg.json: " + err.Error())
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Write writes cfg to libmuttoncfg.json.
|
||||
// If used in append mode, any nil values in the
|
||||
// input cfg will be substituted with the existing values.
|
||||
func Write(cfg *CfgT, appendMode bool) error {
|
||||
start:
|
||||
if appendMode {
|
||||
// check if any fields are nil
|
||||
var hasNilFields bool
|
||||
cfgValue := reflect.ValueOf(&cfg.Libmutton).Elem()
|
||||
for _, field := range cfgValue.Fields() {
|
||||
if field.IsNil() {
|
||||
hasNilFields = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// load old cfg and copy nil fields
|
||||
if hasNilFields {
|
||||
oldCfg, err := Load()
|
||||
if err != nil {
|
||||
// failed to load config, leave append mode
|
||||
appendMode = false
|
||||
goto start
|
||||
}
|
||||
oldValue := reflect.ValueOf(&oldCfg.Libmutton).Elem()
|
||||
for i := 0; i < cfgValue.NumField(); i++ {
|
||||
field := cfgValue.Field(i)
|
||||
if field.IsNil() {
|
||||
field.Set(oldValue.Field(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return errors.New("unable to marshal new/updated cfg: " + err.Error())
|
||||
}
|
||||
if err = os.WriteFile(global.CfgPath, cfgBytes, 0600); err != nil {
|
||||
return errors.New("unable to write new/updated cfg to libmuttoncfg.json: " + err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//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
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
// 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, 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
|
||||
|
||||
// handle non-persistent TOTP copy
|
||||
var realField int
|
||||
if field == -1 {
|
||||
realField = 2
|
||||
} else {
|
||||
realField = field
|
||||
}
|
||||
|
||||
// ensure field exists in entry
|
||||
if len(decryptedEntry) > realField {
|
||||
|
||||
// ensure field is not empty
|
||||
if decryptedEntry[realField] == "" {
|
||||
return errors.New("field is empty")
|
||||
}
|
||||
|
||||
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() 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)
|
||||
}
|
||||
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, error) {
|
||||
var totpToken string
|
||||
var err error
|
||||
|
||||
if forSteam {
|
||||
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 {
|
||||
return "", errors.New("unable to generate TOTP token: " + err.Error())
|
||||
}
|
||||
|
||||
return totpToken, nil
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//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) error {
|
||||
cmd := exec.Command("pbcopy")
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
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")
|
||||
_ = back.WriteToStdin(cmdClear, "")
|
||||
return exec.Command("pbpaste"), cmdClear
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//go:build android && termux
|
||||
|
||||
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) error {
|
||||
cmd := exec.Command("termux-clipboard-set")
|
||||
_ = back.WriteToStdin(cmd, copySubject)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
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")
|
||||
_ = back.WriteToStdin(cmdClear, "")
|
||||
return exec.Command("termux-clipboard-get"), cmdClear
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//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) 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
|
||||
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 {
|
||||
return errors.New("clipboard platform could not be determined")
|
||||
}
|
||||
|
||||
_ = back.WriteToStdin(cmdCopy, copySubject)
|
||||
err := cmdCopy.Run()
|
||||
if err != nil {
|
||||
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.
|
||||
func getClipCommands() (*exec.Cmd, *exec.Cmd) {
|
||||
if os.Args[2] == "true" { // wayland
|
||||
return exec.Command("wl-paste"), exec.Command("wl-copy", "-c")
|
||||
}
|
||||
return exec.Command("xclip", "-o", "-sel", "c"), exec.Command("xclip", "-i", "/dev/null", "-sel", "c")
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//go:build windows || (linux && wsl)
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// copyString copies a string to the clipboard.
|
||||
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 {
|
||||
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) {
|
||||
return exec.Command("powershell.exe", "-c", "Get-Clipboard"), exec.Command("powershell.exe", "-c", "Set-Clipboard")
|
||||
}
|
||||
+6
-6
@@ -10,15 +10,16 @@ import (
|
||||
// GetOldEntryData decrypts and returns old entry data (with all required lines present).
|
||||
// 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)
|
||||
// Leave rcwPassword nil to use RCW demonization.
|
||||
func GetOldEntryData(realPath string, field int, rcwPassword []byte) ([]string, error) {
|
||||
// ensure realPath exists and is a file
|
||||
_, err := back.TargetIsFile(realPath, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// read old entry data
|
||||
decryptedEntry, err := crypt.DecryptFileToSlice(targetLocation)
|
||||
decryptedEntry, err := crypt.DecryptFileToSlice(realPath, rcwPassword)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to decrypt entry: " + err.Error())
|
||||
}
|
||||
@@ -26,9 +27,8 @@ func GetOldEntryData(targetLocation string, field int) ([]string, error) {
|
||||
// return the old entry data with all required lines present
|
||||
if field > 0 {
|
||||
return ensureSliceLength(decryptedEntry, field), nil
|
||||
} else {
|
||||
return decryptedEntry, nil
|
||||
}
|
||||
return decryptedEntry, nil
|
||||
}
|
||||
|
||||
// ensureSliceLength is a utility function that ensures a slice is long enough to contain the specified index.
|
||||
|
||||
+42
-45
@@ -3,19 +3,28 @@ package core
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"strconv"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/cfg"
|
||||
"github.com/rwinkhart/libmutton/config"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccycles"
|
||||
"github.com/rwinkhart/libmutton/syncclient"
|
||||
"github.com/rwinkhart/rcw/wrappers"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// deviceIDPrefix can be left blank to use the system hostname.
|
||||
// clientSpecificCfg can be left nil if not needed.
|
||||
func LibmuttonInit(inputCB func(prompt string) string, rcwPassword []byte, appendMode, forceOfflineMode bool, deviceIDPrefix string, clientSpecificCfg map[string]any) error {
|
||||
// handle clientSpecificCfg
|
||||
newCfg := &config.CfgT{}
|
||||
if clientSpecificCfg != nil {
|
||||
newClientSpecificMap := make(map[string]any)
|
||||
maps.Copy(newClientSpecificMap, clientSpecificCfg)
|
||||
newCfg.ClientSpecific = &newClientSpecificMap
|
||||
}
|
||||
|
||||
var r string
|
||||
if !forceOfflineMode {
|
||||
r = strings.ToLower(inputCB("Configure SSH settings (for synchronization)? (Y/n)"))
|
||||
@@ -24,23 +33,20 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][
|
||||
}
|
||||
if len(r) > 0 && r[0] == 'n' {
|
||||
// initialize libmutton directories
|
||||
_, err := global.DirInit(preserveOldConfigDir)
|
||||
_, err := global.DirInit(appendMode)
|
||||
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())
|
||||
newCfg.Libmutton.OfflineMode = new(true)
|
||||
if err = config.Write(newCfg, false); err != nil {
|
||||
return err
|
||||
}
|
||||
} 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)
|
||||
fallbackSSHKey := global.SSHDir + global.PathSeparator + "id_ed25519"
|
||||
sshKeyPath := cmp.Or(back.ExpandPathWithHome(inputCB("SSH 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())
|
||||
@@ -52,56 +58,47 @@ func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][
|
||||
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)
|
||||
oldDeviceID, err := global.DirInit(appendMode)
|
||||
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())
|
||||
//// temporarily leave sshEntryRootPath, sshAgeDirPath, and sshIsWindows as nil/default to pass initial device ID registration
|
||||
newCfg.Libmutton.OfflineMode = &forceOfflineMode // forceOfflineMode must be false to reach this point, so we can avoid the extra declaration
|
||||
newCfg.Libmutton.SSHUser = new(inputCB("Remote SSH username:"))
|
||||
newCfg.Libmutton.SSHIP = new(inputCB("Remote SSH IP/domain:"))
|
||||
newCfg.Libmutton.SSHPort = new(inputCB("Remote SSH port:"))
|
||||
newCfg.Libmutton.SSHKeyPath = &sshKeyPath
|
||||
newCfg.Libmutton.SSHKeyProtected = &sshKeyProtected
|
||||
if err = config.Write(newCfg, appendMode); err != nil { // pass appendMode to allow not completely destroying existing (client-specific) config
|
||||
return err
|
||||
}
|
||||
// generate and register device ID
|
||||
sshEntryRoot, sshIsWindows, err := synccycles.DeviceIDGen(oldDeviceID, "")
|
||||
sshEntryRoot, sshAgeDir, sshIsWindows, err := syncclient.GenDeviceID(oldDeviceID, deviceIDPrefix)
|
||||
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())
|
||||
}
|
||||
}
|
||||
// generate rcw sanity check file (if requested)
|
||||
if len(rcwPassword) > 0 {
|
||||
err := RCWSanityCheckGen(rcwPassword)
|
||||
if err != nil {
|
||||
// update config file
|
||||
newCfg.Libmutton.SSHEntryRootPath = &sshEntryRoot
|
||||
newCfg.Libmutton.SSHAgeDirPath = &sshAgeDir
|
||||
newCfg.Libmutton.SSHIsWindows = &sshIsWindows
|
||||
if err = config.Write(newCfg, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// generate rcw sanity check file
|
||||
if err := RCWSanityCheckGen(rcwPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if err := wrappers.GenSanityCheck(global.CfgDir+global.PathSeparator+"sanity.rcw", password, true); err != nil {
|
||||
return errors.New("unable to generate sanity check file: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build !windows && !darwin && !android && !ios && !termux && !wsl && !interactive
|
||||
|
||||
package core
|
||||
|
||||
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) {
|
||||
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,10 +0,0 @@
|
||||
//go:build (windows || darwin || android || ios || termux || wsl) && interactive
|
||||
|
||||
package core
|
||||
|
||||
// LaunchClipClearProcess launches the timed clipboard clearing process.
|
||||
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
|
||||
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
|
||||
func LaunchClipClearProcess(copySubject string) {
|
||||
go clipClearProcess(copySubject)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !windows && !darwin && !android && !ios && !termux && !wsl && interactive
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// LaunchClipClearProcess launches the timed clipboard clearing process.
|
||||
// For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine.
|
||||
// copySubject can be omitted to clear the clipboard immediately and unconditionally.
|
||||
func LaunchClipClearProcess(copySubject string, isWayland bool) {
|
||||
os.Args = []string{os.Args[0], "", strconv.FormatBool(isWayland)}
|
||||
go clipClearProcess(copySubject)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/age"
|
||||
"github.com/rwinkhart/libmutton/crypt"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/syncclient"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
"github.com/rwinkhart/rcw/wrappers"
|
||||
)
|
||||
|
||||
// WriteEntry writes decSlice to an encrypted file at realPath.
|
||||
// If the entry contains an updated password, an age file is also created.
|
||||
// Leave rcwPassword nil to use RCW demonization.
|
||||
func WriteEntry(realPath string, decSlice []string, passwordIsNew bool, rcwPassword []byte) error {
|
||||
err := os.WriteFile(realPath, crypt.EncryptBytes([]byte(strings.Join(clampTrailingWhitespace(decSlice), "\n")), rcwPassword), 0600)
|
||||
if err != nil {
|
||||
return errors.New("unable to write to file: " + err.Error())
|
||||
}
|
||||
|
||||
if decSlice != nil {
|
||||
if passwordIsNew { // update age data when password changes
|
||||
if decSlice[0] != "" { // if the password change was NOT a removal, update the age file
|
||||
if err = age.Entry(global.GetVanityPath(realPath), time.Now().Unix()); err != nil {
|
||||
return errors.New("unable to update age data: " + err.Error())
|
||||
}
|
||||
} else { // if the password change was a removal, remove the associated age file
|
||||
if err = syncclient.ShearRemote(global.GetVanityPath(realPath), true); err != nil {
|
||||
return errors.New("unable to remove age data: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntryRefresh re-encrypts all libmutton entries with a new password
|
||||
// and optimizes each entry to ensure they are as slim as possible.
|
||||
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")
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(global.EntryRoot + dirEnd); 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)
|
||||
if err := os.MkdirAll(fullPath, 0700); err != nil {
|
||||
return errors.New("unable to create temporary directory \"" + fullPath + "\": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// decrypt, optimize, and re-encrypt each entry
|
||||
for _, vanityPath := range entries {
|
||||
realPath := global.GetRealPath(vanityPath)
|
||||
encBytes, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to open \"" + realPath + "\" for decryption: " + err.Error())
|
||||
}
|
||||
decBytes, err := wrappers.Decrypt(encBytes, oldRCWPassword, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// split & optimize entry
|
||||
decSlice := clampTrailingWhitespace(strings.Split(string(decBytes), "\n"))
|
||||
|
||||
// re-encrypt the entry with the new password
|
||||
encBytes = wrappers.Encrypt([]byte(strings.Join(decSlice, "\n")), newRCWPassword, true, false)
|
||||
|
||||
// write the entry to the new directory
|
||||
if err = os.WriteFile(global.EntryRoot+"-new"+strings.ReplaceAll(vanityPath, "/", global.PathSeparator), encBytes, 0600); err != nil {
|
||||
return errors.New("unable to write to file: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swap the new directory with the old one
|
||||
if err = os.Rename(global.EntryRoot, global.EntryRoot+"-old"); err != nil {
|
||||
return errors.New("unable to rename old directory: " + err.Error())
|
||||
}
|
||||
if err = os.Rename(global.EntryRoot+"-new", global.EntryRoot); err != nil {
|
||||
return errors.New("unable to rename new directory: " + err.Error())
|
||||
}
|
||||
|
||||
// generate new sanity check file
|
||||
if err = RCWSanityCheckGen(newRCWPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyEntries decrypts all entries to memory and returns an error if
|
||||
// any failures are encountered. Failures likely indicate corrupt entries.
|
||||
func VerifyEntries(rcwPassword []byte) error {
|
||||
entries, _, err := synccommon.WalkEntryDir()
|
||||
if err != nil {
|
||||
return errors.New("unable to walk entry directory: " + err.Error())
|
||||
}
|
||||
for _, vanityPath := range entries {
|
||||
realPath := global.GetRealPath(vanityPath)
|
||||
encBytes, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to open \"" + realPath + "\" for decryption: " + err.Error())
|
||||
}
|
||||
decBytes, err := wrappers.Decrypt(encBytes, rcwPassword, false)
|
||||
if err != nil {
|
||||
return errors.New("unable to verify \"" + vanityPath + "\" (decryption failure): " + err.Error())
|
||||
}
|
||||
if len(decBytes) < 1 {
|
||||
return errors.New("unable to verify \"" + vanityPath + "\" (contains 0/nil bytes)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clampTrailingWhitespace ensures the provided decSlice contains no trailing blank lines.
|
||||
// If decSlice contains a note, it strips trailing newlines, carriage returns, and tabs from
|
||||
// each line in the note. Additionally, it removes single trailing spaces and truncates
|
||||
// multiple trailing spaces to two (for Markdown formatting).
|
||||
func clampTrailingWhitespace(decSlice []string) []string {
|
||||
decSlice = back.RemoveTrailingEmptyStrings(decSlice)
|
||||
if len(decSlice) >= 4 {
|
||||
for i, noteLine := range decSlice[4:] {
|
||||
// remove trailing tabs, carriage returns, and newlines
|
||||
noteLine = strings.TrimRight(noteLine, "\t\r\n")
|
||||
|
||||
// determine the number of trailing spaces in the trimmed line
|
||||
var endSpacesCount int
|
||||
for j := len(noteLine) - 1; j >= 0; j-- {
|
||||
if noteLine[j] != ' ' {
|
||||
break
|
||||
}
|
||||
endSpacesCount++
|
||||
}
|
||||
|
||||
// remove single spaces, truncate multiple spaces (leave two for Markdown formatting)
|
||||
switch endSpacesCount {
|
||||
case 0:
|
||||
// no trailing spaces
|
||||
decSlice[i+4] = noteLine
|
||||
case 1:
|
||||
// remove the single trailing space
|
||||
decSlice[i+4] = strings.TrimRight(noteLine, " ")
|
||||
default:
|
||||
// truncate the trailing spaces to two
|
||||
decSlice[i+4] = noteLine[:len(noteLine)-endSpacesCount+2]
|
||||
}
|
||||
}
|
||||
}
|
||||
return decSlice
|
||||
}
|
||||
|
||||
// EntryAddPrecheck ensures the directory meant to contain a new
|
||||
// entry exists and that realPath is not already used.
|
||||
// Returns: statusCode (0 = success, 1 = realPath already exists, 2 = containing directory is invalid).
|
||||
func EntryAddPrecheck(realPath string) (uint8, error) {
|
||||
// ensure realPath does not already exist
|
||||
isAccessible, _ := back.TargetIsFile(realPath, false) // error is ignored because dir/file status is irrelevant
|
||||
if isAccessible {
|
||||
return 1, errors.New("target location already exists")
|
||||
}
|
||||
// ensure target containing directory exists and is not a file
|
||||
containingDir := realPath[:strings.LastIndex(realPath, 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.
|
||||
func EntryIsNotEmpty(entryData []string) bool {
|
||||
for _, line := range entryData {
|
||||
if line != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GenTOTP generates a TOTP token from a secret.
|
||||
// Prefix `secret` with "steam@" for Steam TOTP format.
|
||||
func GenTOTP(secret string, time time.Time) (string, error) {
|
||||
var totpToken string
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(secret, "steam@") {
|
||||
totpToken, err = totp.GenerateCodeCustom(secret[6:], time, totp.ValidateOpts{Period: 30, Digits: 5, Encoder: otp.EncoderSteam})
|
||||
} else {
|
||||
totpToken, err = totp.GenerateCode(secret, time)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", errors.New("unable to generate TOTP token: " + err.Error())
|
||||
}
|
||||
|
||||
return totpToken, nil
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
// WriteEntry writes entryData to an encrypted file at targetLocation.
|
||||
func WriteEntry(targetLocation string, decBytes []byte) error {
|
||||
err := os.WriteFile(targetLocation, crypt.EncryptBytes(decBytes), 0600)
|
||||
if err != nil {
|
||||
return errors.New("unable to write to file: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ClampTrailingWhitespace strips trailing newlines, carriage returns, and tabs from each line in a note.
|
||||
// Additionally, it removes single trailing spaces and truncates multiple trailing spaces to two (for Markdown formatting).
|
||||
func ClampTrailingWhitespace(note []string) {
|
||||
for i, line := range note {
|
||||
// remove trailing tabs, carriage returns, and newlines
|
||||
line = strings.TrimRight(line, "\t\r\n")
|
||||
|
||||
// determine the number of trailing spaces in the trimmed line
|
||||
var endSpacesCount int
|
||||
for j := len(line) - 1; j >= 0; j-- {
|
||||
if line[j] != ' ' {
|
||||
break
|
||||
}
|
||||
endSpacesCount++
|
||||
}
|
||||
|
||||
// remove single spaces, truncate multiple spaces (leave two for Markdown formatting)
|
||||
switch endSpacesCount {
|
||||
case 0:
|
||||
// no trailing spaces
|
||||
note[i] = line
|
||||
case 1:
|
||||
// remove the single trailing space
|
||||
note[i] = strings.TrimRight(line, " ")
|
||||
default:
|
||||
// truncate the trailing spaces to two
|
||||
note[i] = line[:len(line)-endSpacesCount+2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
// 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.
|
||||
func EntryIsNotEmpty(entryData []string) bool {
|
||||
for _, line := range entryData {
|
||||
if line != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+43
-33
@@ -13,66 +13,77 @@ import (
|
||||
"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)
|
||||
if password == nil {
|
||||
os.Exit(0) // use os.Exit directly since this function is only intended for non-interactive CLI clients
|
||||
}
|
||||
daemon.Start([]byte(password))
|
||||
daemon.Start(password)
|
||||
}
|
||||
|
||||
// DecryptFileToSlice decrypts an RCW wrapped file and returns the contents as a slice of (trimmed) strings.
|
||||
func DecryptFileToSlice(targetLocation string) ([]string, error) {
|
||||
// DecryptFileToSlice decrypts an RCW wrapped file
|
||||
// and returns the contents as a slice of (trimmed) strings.
|
||||
// Leave rcwPassword nil to use RCW demonization.
|
||||
func DecryptFileToSlice(realPath string, rcwPassword []byte) ([]string, error) {
|
||||
// read encrypted file
|
||||
encBytes, err := os.ReadFile(targetLocation)
|
||||
encBytes, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to open \"" + targetLocation + "\" for decryption: " + err.Error())
|
||||
return nil, errors.New("unable to open \"" + realPath + "\" 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 no password was provided, assume daemon mode
|
||||
if rcwPassword == nil {
|
||||
rcwPassword = launchRCWDProcess()
|
||||
// if rcwPassword is still nil, the daemon is already running;
|
||||
// use it to encrypt the data
|
||||
if rcwPassword == nil {
|
||||
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 the daemon is not being used/was not already running,
|
||||
// use wrappers.Decrypt directly to avoid waiting for socket file creation
|
||||
decBytes, err := wrappers.Decrypt(encBytes, rcwPassword, true)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to decrypt \"" + targetLocation + "\": " + err.Error())
|
||||
return nil, errors.New("unable to decrypt \"" + realPath + "\": " + 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)
|
||||
// Leave rcwPassword nil to use RCW demonization.
|
||||
// If providing rcwPassword, it is up to the client to perform the sanity check!
|
||||
func EncryptBytes(decBytes, rcwPassword []byte) []byte {
|
||||
// if no rcwPassword was provided, assume daemon mode
|
||||
if rcwPassword == nil {
|
||||
rcwPassword = launchRCWDProcess()
|
||||
// if rcwPassword is still nil, the daemon is already running;
|
||||
// use it to encrypt the data
|
||||
if rcwPassword == nil {
|
||||
return daemon.GetEnc(decBytes, true)
|
||||
}
|
||||
}
|
||||
// if the daemon is not already running, use wrappers.Encrypt
|
||||
// directly to avoid waiting for socket file creation
|
||||
return wrappers.Encrypt(decBytes, password)
|
||||
|
||||
// if the daemon is not being used/was not already running,
|
||||
// use wrappers.Encrypt directly to avoid waiting for socket file creation
|
||||
return wrappers.Encrypt(decBytes, rcwPassword, true, true)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if 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 {
|
||||
if err := wrappers.RunSanityCheck(global.CfgDir+global.PathSeparator+"sanity.rcw", password); err == nil {
|
||||
break
|
||||
}
|
||||
fmt.Println(back.AnsiError + "Incorrect password" + back.AnsiReset)
|
||||
@@ -82,11 +93,10 @@ func launchRCWDProcess() []byte {
|
||||
password = global.GetPassword("RCW Password:")
|
||||
}
|
||||
|
||||
if Daemonize {
|
||||
cmd := exec.Command(os.Args[0], "startrcwd")
|
||||
_ = back.WriteToStdin(cmd, string(password))
|
||||
_ = cmd.Start()
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "startrcwd")
|
||||
cmd.SysProcAttr = global.GetSysProcAttr()
|
||||
_ = back.WriteToStdin(cmd, password, false)
|
||||
_ = cmd.Start()
|
||||
|
||||
return password
|
||||
}
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
**libmutton v0.4.4**
|
||||
November 23, 2025
|
||||
|
||||
This release fixes a minor bug and dependency-derived security issues.
|
||||
|
||||
## Fixes
|
||||
- (711515f3a1e368c28abb63c25b6a79a309844645) Fix subprocesses being killed prematurely when the main process is force quit
|
||||
- Fixes issues with master password caching and clipboard clearing
|
||||
|
||||
## Dependencies
|
||||
- Bumped (direct/replaced)
|
||||
- golang.org/x/crypto: v0.43.0 => v0.45.0
|
||||
- Addresses CVE-2025-58181 and CVE-2025-47914
|
||||
- github.com/rwinkhart/rcw: v0.2.3 => v0.2.4
|
||||
|
||||
---
|
||||
|
||||
**libmutton v0.4.2**
|
||||
November 09, 2025
|
||||
|
||||
This is purely a dependency bump release.
|
||||
|
||||
## Dependencies
|
||||
- Bumped (direct/replaced)
|
||||
- Go: v1.25.3 => v1.25.4
|
||||
- github.com/rwinkhart/rcw: v0.2.2 => v0.2.3
|
||||
- github.com/rwinkhart/peercred-mini: v0.1.1 => v0.1.2
|
||||
- github.com/rwinkhart/sys: v0.37.0 => v0.38.0
|
||||
- github.com/rwinkhart/go-winio-easy-pipe-handles: switched to tagged v0.1.0 of `github.com/rwinkhart/go-winio`
|
||||
|
||||
---
|
||||
|
||||
**libmutton v0.4.1**
|
||||
October 26, 2025
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
**libmutton v0.5.0**
|
||||
February 17, 2026
|
||||
|
||||
This release adds password age-tracking support and features large refactors that improve the robustness of core functionality. Many breaking changes are present. Highlights are listed below.
|
||||
|
||||
## Breaking (for users+developers)
|
||||
- **USERS**: These changes *require* updating your copy of `libmuttonserver`; additionally, you *must* re-generate your config file however your client allows (e.g. `mutn init`)
|
||||
- (50d51f9d96cca194bc89206fdd56de4db7a696a5) Password age tracking support
|
||||
- libmutton now tracks when passwords were last updated
|
||||
- Clients can use this information to warn users when passwords should be changed
|
||||
- Random (within the last year) age data can be added to existing password entries
|
||||
- This allows easing into this feature, rather than having all your passwords expire on the same day
|
||||
- Developers: see the new `age` package
|
||||
- (e355c810da06d794fd5a6ed10b8a886afa7f3492) (f16078fe01ec8a14fd7f2cd059340b33908ca1d6) (4e6c18db8a6716dcf1a9b73fd523f2f7161ce7f2) (2ab777fd571bb9b79e7601667e5c1f5b15400d03) (941a2dc8b80cb6e0327b33714c4a894962621bda) (35db3bf20b8ab3705bf349fdc7ed4b8e7899cddb) (5e5a7c57e9c5633edb74a968e46f21e141ab81ef) (07571a698d32eb414c24cdf84327d1868a0b455a) (d0e8640ce6e90583a7ea5ff5b3ed2cfdc2e00801) (3b4e75b592241b8405c4cb49dd40859773b7b027) Massive sync refactor
|
||||
- Sync info is now passed from the server to the client using rigidly defined JSON types
|
||||
- This greatly improves code maintainability and reduces potential for bugs
|
||||
- Sync now supports password age tracking data
|
||||
- (b591e2ef5a6da7f8fbee099c25349dd61433b297) (571bc0a3bd8773771c01191d9b7b8f03c7c5b436) New config file format
|
||||
- `libmutton.ini` is now `libmuttoncfg.json`
|
||||
- This format change was made since the JSON package was being adopted for the sync refactor (the `gopkg.in/ini.v1` dependency can now be dropped)
|
||||
- Developers: see the new `config` package, replacing the old `cfg` package
|
||||
- (6d3e3cdb5bcfccd103aadfcbb4789ce001bad946) Dropped built-in Termux support to simplify builds (clients can still implement this on their own)
|
||||
|
||||
## Breaking (for developers)
|
||||
- (bf1399ce8d6e39d04d3cdad2d99ae787c88effdf) (97037903360c70221e138b22630e7ef90ec378bc) (a0f21b31f743eab4cc9d0f879e3b5fc4d7627ad0) (80cf65775ada4ce7c0f1c6c3ab93d5bb561518ad) (8d1dd48035bd3abb6f4b8847c5a39a61be53f3a4) (c63dff409210e438c27a429b9fdb023a7bba66af) (336dc5c158af21214bd097c2a0eadbc44f7c2cf6) (04e1b0dd37d372fb93c168273761a7285f819dc8) (d2a6ed5eaa6600a44912edbebb3b2df0a78320b0) (fdce036be6d42cf8ab97a9fb66f88b243639252f) (6004a94b7718a97bdc847ea99a8a39761c7b0075) Massive clipboard refactor
|
||||
- Added new `clip` package
|
||||
- Non-continuous TOTP copy must use `core.GenTOTP` directly
|
||||
- The typical copy workflow now only supports continuous copy
|
||||
- Dropped mobile clipboard support
|
||||
- This can/should be handled by the GUI toolkit
|
||||
- (28286788a045a090be67b4f2919b09a8be570801) (a18c3ea1d6412dbb88fba5a5f69693450be9e97c) (5e5a7c57e9c5633edb74a968e46f21e141ab81ef) (4467a8c7421bb4b60e5088bca851c1fd7d4fec50) `core.LibmuttonInit` overhaul
|
||||
- Now supports non-destructive append
|
||||
- Now supports custom device ID prefixes
|
||||
|
||||
## New (non-breaking)
|
||||
- (cb3a554676ff8d43f4350f6ce74af786c003bf3b) Mass entry verification
|
||||
- (d7242fee96b32b6b1ba4cec6952b143019b59b78) (1351360b35b37763fdb418d6d600c86a1e03b360) Native iOS support
|
||||
|
||||
## Fixes
|
||||
- (2054f5b3cf2d3136b3704ddf01a6861eaa7e5ebf) Reduced clipboard latency on Windows
|
||||
- (f82b35bc4398030568a38cd806b5e017811f10a1) Fixed background processes dying w/terminal on Windows
|
||||
|
||||
## Dependencies (direct/replaced)
|
||||
- Dropped
|
||||
- golang.design/x/clipboard
|
||||
- gopkg.in/ini.v1
|
||||
- Bumped
|
||||
- Go: v1.25.4 => v1.26.0
|
||||
- github.com/rwinkhart/go-boilerplate: v0.1.0 => v0.3.1
|
||||
- github.com/rwinkhart/rcw: v0.2.4 => v0.3.0
|
||||
- golang.org/x/crypto: v0.45.0 => v0.48.0
|
||||
- github.com/rwinkhart/sys: v0.38.0 => v0.41.0
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
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
|
||||
GetPassword func(prompt string) []byte // Clients should set this to a function that fetches hidden input from the user
|
||||
RootLength = len(EntryRoot)
|
||||
)
|
||||
|
||||
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"
|
||||
LibmuttonVersion = "0.5.0" // Untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.B.0" -> "0.2.0", "0.2.A" -> "0.2.1"
|
||||
|
||||
FSSpace = "\u259d" // ▝ Space/list separator
|
||||
FSPath = "\u259e" // ▞ Path separator
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//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
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
//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,27 @@
|
||||
//go:build ios
|
||||
|
||||
package global
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
)
|
||||
|
||||
func init() {
|
||||
back.Home = fruityHome
|
||||
}
|
||||
|
||||
var (
|
||||
fruityHome = filepath.Join(back.Home, "Library", "Application Support", "libmutton-ios") // private, non-iCloud-backed dir
|
||||
EntryRoot = fruityHome + "/entries" // Path to libmutton entry directory
|
||||
CfgDir = fruityHome + "/config" // Path to libmutton configuration directory
|
||||
CfgPath = CfgDir + "/libmuttoncfg.json" // Path to libmutton configuration file
|
||||
AgeDir = CfgDir + "/age" // Path to libmutton password age directory
|
||||
SSHDir = fruityHome + "/ssh" // Path to SSH directory
|
||||
)
|
||||
|
||||
const (
|
||||
PathSeparator = "/" // Platform-specific path separator
|
||||
IsWindows = false // Platform indicator
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package global
|
||||
|
||||
import "github.com/rwinkhart/go-boilerplate/back"
|
||||
|
||||
var (
|
||||
EntryRoot = back.Home + "/.local/share/libmutton" // Path to libmutton entry directory
|
||||
CfgDir = back.Home + "/.config/libmutton" // Path to libmutton configuration directory
|
||||
CfgPath = CfgDir + "/libmuttoncfg.json" // Path to libmutton configuration file
|
||||
AgeDir = CfgDir + "/age" // Path to libmutton password age directory
|
||||
SSHDir = back.Home + "/.ssh" // Path to SSH directory
|
||||
)
|
||||
|
||||
const (
|
||||
PathSeparator = "/" // Platform-specific path separator
|
||||
IsWindows = false // Platform indicator
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
//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
|
||||
CfgDir = back.Home + "\\AppData\\Local\\libmutton\\config" // Path to libmutton configuration directory
|
||||
CfgPath = CfgDir + "\\libmuttoncfg.json" // Path to libmutton configuration file
|
||||
AgeDir = CfgDir + "\\age" // Path to libmutton password age directory
|
||||
SSHDir = back.Home + "\\.ssh" // Path to SSH directory
|
||||
)
|
||||
|
||||
const (
|
||||
PathSeparator = "\\" // Platform-specific path separator
|
||||
IsWindows = true // Platform indicator
|
||||
)
|
||||
+6
-6
@@ -7,26 +7,26 @@ import (
|
||||
)
|
||||
|
||||
// GetCurrentDeviceID returns the current device ID or
|
||||
// FSMisc if there is no device ID (e.g. first run).
|
||||
func GetCurrentDeviceID() (string, error) {
|
||||
// nil 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())
|
||||
return nil, 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 nil, nil // nil device ID indicates to server that no device ID is being replaced
|
||||
}
|
||||
return deviceID, nil
|
||||
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")
|
||||
deviceIDList, err := os.ReadDir(CfgDir + PathSeparator + "devices")
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to read devices directory: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package global
|
||||
|
||||
import "strings"
|
||||
|
||||
// GetRealAgePath returns the full path to an age file (given the vanity path)
|
||||
func GetRealAgePath(vanityPath string) string {
|
||||
return AgeDir + PathSeparator + strings.ReplaceAll(vanityPath, "/", FSPath)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package global
|
||||
|
||||
// GetRealPath returns the full path to an entry (given the vanity path).
|
||||
func GetRealPath(vanityPath string) string {
|
||||
return EntryRoot + vanityPath
|
||||
}
|
||||
|
||||
// GetVanityPath returns a vanityPath given a realPath
|
||||
func GetVanityPath(realPath string) string {
|
||||
return realPath[RootLength:]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package global
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetRealPath returns the full path to an entry (given the vanity path).
|
||||
func GetRealPath(vanityPath string) string {
|
||||
return EntryRoot + strings.ReplaceAll(vanityPath, "/", PathSeparator)
|
||||
}
|
||||
|
||||
// GetVanityPath returns a vanityPath given a realPath
|
||||
func GetVanityPath(realPath string) string {
|
||||
return strings.ReplaceAll(realPath[RootLength:], "\\", "/")
|
||||
}
|
||||
+17
-16
@@ -9,34 +9,35 @@ import (
|
||||
|
||||
// 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) {
|
||||
func DirInit(preserveOldCfgDir bool) (*string, error) {
|
||||
var err error
|
||||
|
||||
// create EntryRoot
|
||||
err := os.MkdirAll(EntryRoot, 0700)
|
||||
if err != nil {
|
||||
return "", errors.New("unable to create \"" + EntryRoot + "\": " + err.Error())
|
||||
if err = os.MkdirAll(EntryRoot, 0700); err != nil {
|
||||
return nil, errors.New("unable to create \"" + EntryRoot + "\": " + err.Error())
|
||||
}
|
||||
|
||||
// get old device ID before its potential removal
|
||||
oldDeviceID, err := GetCurrentDeviceID()
|
||||
if err != nil {
|
||||
oldDeviceID = FSMisc
|
||||
}
|
||||
oldDeviceID, _ := GetCurrentDeviceID() // error ignored; oldDeviceID is set to nil on error, which is the correct assumption
|
||||
|
||||
// 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 !preserveOldCfgDir {
|
||||
isAccessible, _ := back.TargetIsFile(CfgDir, 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())
|
||||
if err = os.RemoveAll(CfgDir); err != nil {
|
||||
return nil, 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())
|
||||
if err = os.MkdirAll(CfgDir+PathSeparator+"devices", 0700); err != nil {
|
||||
return nil, errors.New("unable to create \"" + CfgDir + "\": " + err.Error())
|
||||
}
|
||||
|
||||
// create password age directory
|
||||
if err = os.MkdirAll(AgeDir, 0700); err != nil {
|
||||
return nil, errors.New("unable to create \"" + AgeDir + "\": " + err.Error())
|
||||
}
|
||||
|
||||
return oldDeviceID, nil
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package global
|
||||
|
||||
import "syscall"
|
||||
|
||||
func GetSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package global
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func GetSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package global
|
||||
|
||||
// TargetLocationFormat returns the full location of an entry (given the name) formatted for the current platform.
|
||||
func TargetLocationFormat(targetLocationIncomplete string) string {
|
||||
return EntryRoot + targetLocationIncomplete
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package global
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TargetLocationFormat returns the full location of an entry (given the name) formatted for the current platform.
|
||||
func TargetLocationFormat(targetLocationIncomplete string) string {
|
||||
return EntryRoot + strings.ReplaceAll(targetLocationIncomplete, "/", PathSeparator)
|
||||
}
|
||||
@@ -1,28 +1,23 @@
|
||||
module github.com/rwinkhart/libmutton
|
||||
|
||||
go 1.25.3
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
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
|
||||
github.com/rwinkhart/go-boilerplate v0.3.1
|
||||
github.com/rwinkhart/rcw v0.3.1
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
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
|
||||
github.com/rwinkhart/peercred-mini v0.1.5 // indirect
|
||||
)
|
||||
|
||||
replace golang.org/x/sys => github.com/rwinkhart/sys v0.37.0
|
||||
replace golang.org/x/sys => github.com/rwinkhart/sys v0.45.0
|
||||
|
||||
replace github.com/Microsoft/go-winio => github.com/rwinkhart/go-winio-easy-pipe-handles v0.0.0-20250407031321-96994a0e8410
|
||||
replace github.com/Microsoft/go-winio => github.com/rwinkhart/go-winio v0.1.1
|
||||
|
||||
@@ -12,33 +12,23 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/rwinkhart/go-boilerplate v0.3.1 h1:vkVRuptO2s1yPzzwpvtiiB0c/hPB6yr0p1mzZ2Myb9E=
|
||||
github.com/rwinkhart/go-boilerplate v0.3.1/go.mod h1:ES13A2r9fnCVfyezwMBgY/RgA4pOIudOUXz3Jk/ikes=
|
||||
github.com/rwinkhart/go-winio v0.1.1 h1:kAJKiqneR7cUR01Wn5/doAAV4kOGTEGPug4oinXc5N4=
|
||||
github.com/rwinkhart/go-winio v0.1.1/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0=
|
||||
github.com/rwinkhart/peercred-mini v0.1.5 h1:9sBR0ascteCJfzfhv7Gq/juqu7OOU+AphiEIG1+LcWA=
|
||||
github.com/rwinkhart/peercred-mini v0.1.5/go.mod h1:C73IeDteQwsKp1+PDdUBj4FC6dTziAJNbQsVDZSWE+M=
|
||||
github.com/rwinkhart/rcw v0.3.1 h1:qmozfcoEm71ttA8YSoH96y42Z2sFhCvY45GGtFPWVhg=
|
||||
github.com/rwinkhart/rcw v0.3.1/go.mod h1:TreA2OUl64v2UprL4xpXdDM7w8lJNzQ544rUIl8omtI=
|
||||
github.com/rwinkhart/sys v0.45.0 h1:HXZL0SuyToqKkvakaUuRAdH8sPUHelfO6q4PQRpL26Q=
|
||||
github.com/rwinkhart/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+90
-30
@@ -2,9 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
@@ -14,8 +15,6 @@ import (
|
||||
"github.com/rwinkhart/libmutton/syncserver"
|
||||
)
|
||||
|
||||
const ansiBold = "\033[1m"
|
||||
|
||||
func main() {
|
||||
args := os.Args
|
||||
if len(args) < 2 {
|
||||
@@ -35,6 +34,23 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// allow reporting panic details to clients
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Printf(
|
||||
"{\"errMsg\":\"SERVER-SIDE PANIC OCCURRED: %v%sARGS: %v%sSTDIN: %v%sSTACK TRACE: %s\"}",
|
||||
r,
|
||||
global.FSSpace+global.FSSpace,
|
||||
global.FSSpace+strings.Join(os.Args, global.FSSpace),
|
||||
global.FSSpace+global.FSSpace,
|
||||
global.FSSpace+strings.Join(stdin, global.FSSpace),
|
||||
global.FSSpace+global.FSSpace,
|
||||
global.FSSpace+strings.ReplaceAll(strings.ReplaceAll(string(debug.Stack()), "\n", global.FSSpace), "\t", ""),
|
||||
)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
switch args[1] {
|
||||
case "fetch":
|
||||
// print all information needed for syncing to stdout for interpretation by the client
|
||||
@@ -43,47 +59,91 @@ func main() {
|
||||
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
|
||||
_ = synccommon.RenameLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), strings.ReplaceAll(stdin[2], global.FSPath, "/"))
|
||||
// stdin[1] is expected to be the OLD vanityPath with FSPath representing path separators - Always pass in UNIX format
|
||||
// stdin[2] is expected to be the NEW vanityPath with FSPath representing path separators - Always pass in UNIX format
|
||||
if err := synccommon.RenameLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), strings.ReplaceAll(stdin[2], global.FSPath, "/")); err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
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
|
||||
_, _, _ = synccommon.ShearLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), stdin[0])
|
||||
// stdin[1] is expected to be the vanityPath with FSPath representing path separators - Always pass in UNIX format
|
||||
_, _, err := synccommon.ShearLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), stdin[0], false)
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
case "shear-age":
|
||||
// shear ONLY the age file associated with 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 vanityPath with FSPath representing path separators - Always pass in UNIX format
|
||||
_, _, err := synccommon.ShearLocal(strings.ReplaceAll(stdin[1], global.FSPath, "/"), stdin[0], true)
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
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
|
||||
_ = synccommon.AddFolderLocal(strings.ReplaceAll(stdin[0], global.FSPath, "/"))
|
||||
// stdin[0] is expected to be the vanityPath with FSPath representing path separators - Always pass in UNIX format
|
||||
if err := synccommon.AddFolderLocal(strings.ReplaceAll(stdin[0], global.FSPath, "/")); err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
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(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] != global.FSMisc { // FSMisc is used to indicate that no device ID is being replaced
|
||||
// stdin[0] is expected to be JSON matching type synccommon.RegisterReqT
|
||||
var registerReq synccommon.RegisterReqT
|
||||
err := json.Unmarshal([]byte(stdin[0]), ®isterReq)
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(global.CfgDir+global.PathSeparator+"devices"+global.PathSeparator+registerReq.NewDeviceID, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
if registerReq.OldDeviceID != nil { // nil 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])
|
||||
if err = os.RemoveAll(global.CfgDir + global.PathSeparator + "devices" + global.PathSeparator + *registerReq.OldDeviceID); err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
// 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])
|
||||
deletionsDirRoot := global.CfgDir + global.PathSeparator + "deletions" + global.PathSeparator
|
||||
deletionsList, err := os.ReadDir(deletionsDirRoot)
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
for i := range deletionsList {
|
||||
affectedIDVanityPath := strings.Split(deletionsList[i].Name(), global.FSSpace)
|
||||
if affectedIDVanityPath[0] == *registerReq.OldDeviceID {
|
||||
if err = os.Rename(deletionsDirRoot+deletionsList[i].Name(), deletionsDirRoot+registerReq.NewDeviceID+global.FSSpace+affectedIDVanityPath[1]+global.FSSpace+affectedIDVanityPath[2]); err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// print EntryRoot and bool indicating OS type to stdout for client to store in config
|
||||
fmt.Print(global.EntryRoot + global.FSSpace + strconv.FormatBool(global.IsWindows))
|
||||
|
||||
// print EntryRoot, AgeDir and bool indicating OS type to stdout for client to store in config
|
||||
registerRespBytes, err := json.Marshal(synccommon.RegisterRespT{EntryRoot: global.EntryRoot, AgeDir: global.AgeDir, IsWindows: global.IsWindows})
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Print(string(registerRespBytes))
|
||||
case "init":
|
||||
// create the necessary directories for libmuttonserver to function
|
||||
_, 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
|
||||
_ = os.MkdirAll(global.CfgDir+global.PathSeparator+"deletions", 0700) // error ignored; failure would have occurred by this point in core.DirInit
|
||||
fmt.Println("libmuttonserver directories initialized")
|
||||
case "version":
|
||||
versionServer()
|
||||
@@ -93,13 +153,13 @@ func main() {
|
||||
}
|
||||
|
||||
func helpServer() {
|
||||
fmt.Print(ansiBold + "\nlibmuttonserver | Copyright (c) 2024-2025 Randall Winkhart\n" + back.AnsiReset + `
|
||||
fmt.Print(back.AnsiBold + "\nlibmuttonserver | Copyright (c) 2024-2026 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:" + back.AnsiReset + ` libmuttonserver <argument>
|
||||
` + back.AnsiBold + "Usage:" + back.AnsiReset + ` libmuttonserver <argument>
|
||||
|
||||
` + ansiBold + "Arguments (user):" + back.AnsiReset + `
|
||||
` + back.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")
|
||||
@@ -107,7 +167,7 @@ This program comes with absolutely no warranty; type "libmuttonserver version" f
|
||||
}
|
||||
|
||||
func versionServer() {
|
||||
fmt.Print(ansiBold + "\n MIT License" + back.AnsiReset + `
|
||||
fmt.Print(back.AnsiBold + "\n MIT License" + back.AnsiReset + `
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
@@ -132,5 +192,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" + back.AnsiReset + " Version " + global.LibmuttonVersion + "\n\n Copyright (c) 2024-2025: Randall Winkhart" + "\n\n")
|
||||
fmt.Print(back.AnsiBold + "\n\n libmuttonserver" + back.AnsiReset + " Version " + global.LibmuttonVersion + "\n\n Copyright (c) 2024-2026: Randall Winkhart" + "\n\n")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
mkdir -p ./1output
|
||||
cd ..
|
||||
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=linux CGO_ENABLED=0 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 CGO_ENABLED=0 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
|
||||
GOOS=windows CGO_ENABLED=0 GOARCH=arm64 GOARM64=v8.7 CGO_ENABLED=0 go build -o ./packaging/1output/libmuttonserver-windows-arm64v8.7.exe -ldflags="-s -w" -trimpath ./libmuttonserver.go
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package privkey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
var keyBytesProvider = func(sshKeyPath *string) ([]byte, error) {
|
||||
if sshKeyPath != nil {
|
||||
key, err := os.ReadFile(*sshKeyPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to read private key: " + *sshKeyPath)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("unable to identify private key location (nil config)")
|
||||
}
|
||||
|
||||
// GetBytes returns the SSH private key bytes.
|
||||
// On iOS, it calls the provider function that was set by the iOS app
|
||||
// using SetKeyBytesProvider or SetKeyBytes.
|
||||
func GetBytes(sshKeyPath *string) ([]byte, error) {
|
||||
keyBytes, err := keyBytesProvider(sshKeyPath)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to get key bytes from provider: " + err.Error())
|
||||
}
|
||||
if len(keyBytes) == 0 {
|
||||
return nil, errors.New("provider returned empty key bytes")
|
||||
}
|
||||
return keyBytes, nil
|
||||
}
|
||||
|
||||
// SetBytes allows the iOS app to directly set the SSH key bytes.
|
||||
func SetBytes(keyBytes []byte) {
|
||||
keyBytesProvider = func(*string) ([]byte, error) {
|
||||
return keyBytes, nil
|
||||
}
|
||||
}
|
||||
+187
-241
@@ -1,22 +1,30 @@
|
||||
package syncclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/cfg"
|
||||
"github.com/rwinkhart/libmutton/age"
|
||||
"github.com/rwinkhart/libmutton/config"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/privkey"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
type syncListsT struct {
|
||||
Delete []string
|
||||
Download []string
|
||||
Upload []string
|
||||
}
|
||||
|
||||
// GetSSHClient
|
||||
// Returns:
|
||||
// sshClient,
|
||||
@@ -24,68 +32,43 @@ import (
|
||||
// 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) {
|
||||
func GetSSHClient() (*ssh.Client, bool, *bool, *string, *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
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to parse SSH config: " + err.Error())
|
||||
return nil, false, nil, nil, nil, errors.New("unable to parse SSH config: " + err.Error())
|
||||
}
|
||||
if *cfg.Libmutton.OfflineMode {
|
||||
return nil, true, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
var user, ip, port, keyFile, keyFileProtected, entryRoot string
|
||||
var isWindows bool
|
||||
for i, key := range sshUserConfig {
|
||||
switch i {
|
||||
case 1:
|
||||
user = key
|
||||
case 2:
|
||||
ip = key
|
||||
case 3:
|
||||
port = key
|
||||
case 4:
|
||||
keyFile = key
|
||||
case 5:
|
||||
keyFileProtected = key
|
||||
case 6:
|
||||
entryRoot = key
|
||||
case 7:
|
||||
isWindows, err = strconv.ParseBool(key)
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to parse server OS type: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// read private key
|
||||
key, err := os.ReadFile(keyFile)
|
||||
// get private key
|
||||
key, err := privkey.GetBytes(cfg.Libmutton.SSHKeyPath)
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to read private key: " + keyFile)
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
|
||||
// parse private key
|
||||
var parsedKey ssh.Signer
|
||||
if keyFileProtected != "true" {
|
||||
if !*cfg.Libmutton.SSHKeyProtected {
|
||||
parsedKey, err = ssh.ParsePrivateKey(key)
|
||||
} else {
|
||||
parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, global.GetPassword("Enter password for your SSH keyfile:"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to parse private key: " + keyFile)
|
||||
return nil, false, nil, nil, nil, errors.New("unable to parse private key: " + err.Error())
|
||||
}
|
||||
|
||||
// read known hosts file
|
||||
var hostKeyCallback ssh.HostKeyCallback
|
||||
hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts")
|
||||
hostKeyCallback, err = knownhosts.New(global.SSHDir + global.PathSeparator + "known_hosts")
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to read known hosts file: " + err.Error())
|
||||
return nil, false, nil, nil, nil, errors.New("unable to read known hosts file: " + err.Error())
|
||||
}
|
||||
|
||||
// configure SSH client
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: user,
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: *cfg.Libmutton.SSHUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(parsedKey),
|
||||
},
|
||||
@@ -94,20 +77,20 @@ func GetSSHClient() (*ssh.Client, bool, bool, string, error) {
|
||||
}
|
||||
|
||||
// connect to SSH server
|
||||
sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig)
|
||||
sshClient, err := ssh.Dial("tcp", *cfg.Libmutton.SSHIP+":"+*cfg.Libmutton.SSHPort, sshCfg)
|
||||
if err != nil {
|
||||
return nil, false, false, "", errors.New("unable to connect to remote server: " + err.Error())
|
||||
return nil, false, nil, nil, nil, errors.New("unable to connect to remote server: " + err.Error())
|
||||
}
|
||||
|
||||
return sshClient, false, isWindows, entryRoot, nil
|
||||
return sshClient, false, cfg.Libmutton.SSHIsWindows, cfg.Libmutton.SSHEntryRootPath, cfg.Libmutton.SSHAgeDirPath, nil
|
||||
}
|
||||
|
||||
// GetSSHOutput runs a command over SSH and returns the output as a string.
|
||||
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) (string, error) {
|
||||
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) ([]byte, error) {
|
||||
// create a session
|
||||
sshSession, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return "", errors.New("unable to establish SSH session: " + err.Error())
|
||||
return nil, errors.New("unable to establish SSH session: " + err.Error())
|
||||
}
|
||||
|
||||
// provide stdin data for session
|
||||
@@ -117,100 +100,61 @@ func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) (string, error) {
|
||||
var output []byte
|
||||
output, err = sshSession.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
return "", errors.New("unable to run SSH command: " + err.Error())
|
||||
return nil, 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, nil
|
||||
return output, 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) (map[string]int64, []string, []string, int64, int64, error) {
|
||||
// getRemoteDataFromClient returns:
|
||||
// a map of remote vanityPaths to their containing folders and mod+age timestamps,
|
||||
// a list of queued deletions,
|
||||
// and the current server&client times as UNIX timestamps.
|
||||
func getRemoteDataFromClient(sshClient *ssh.Client) (synccommon.EntryMapT, []synccommon.Deletion, int64, int64, error) {
|
||||
// get remote output over SSH
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, err
|
||||
return nil, nil, 0, 0, err
|
||||
}
|
||||
if len(deviceIDList) == 0 {
|
||||
return nil, nil, nil, 0, 0, errors.New("no device ID found")
|
||||
return nil, nil, 0, 0, errors.New("no device ID found")
|
||||
}
|
||||
clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time
|
||||
output, err := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name())
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to run remote command: " + err.Error())
|
||||
return 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, global.FSSpace)
|
||||
|
||||
// parse output/re-form lists
|
||||
if len(outputSlice) != 5 { // ensure information from server is complete
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to run remote command; server returned an unexpected response")
|
||||
var fetchResp synccommon.FetchRespT
|
||||
if err = json.Unmarshal(output, &fetchResp); err != nil {
|
||||
fmt.Println(string(output))
|
||||
return nil, nil, 0, 0, errors.New("unable to unmarshal server fetch response: " + err.Error())
|
||||
}
|
||||
serverTime, err := strconv.ParseInt(outputSlice[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to parse server time: " + err.Error())
|
||||
}
|
||||
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
|
||||
var mod int64
|
||||
for _, modString := range modsStrings {
|
||||
mod, err = strconv.ParseInt(modString, 10, 64)
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to parse mod time: " + err.Error())
|
||||
}
|
||||
mods = append(mods, mod)
|
||||
if fetchResp.ErrMsg != nil {
|
||||
return nil, nil, 0, 0, errors.New("unable to complete fetch; server-side error occurred: " + strings.ReplaceAll(*fetchResp.ErrMsg, global.FSSpace, "\n"))
|
||||
}
|
||||
|
||||
// map remote entries to their modification times
|
||||
entryModMap := make(map[string]int64)
|
||||
for i, entry := range entries {
|
||||
entryModMap[entry] = mods[i]
|
||||
}
|
||||
|
||||
return entryModMap, folders, deletions, serverTime, clientTime, nil
|
||||
return fetchResp.Entries, fetchResp.Deletions, fetchResp.ServerTime, clientTime, nil
|
||||
}
|
||||
|
||||
// getLocalData returns a map of local entries to their modification times.
|
||||
func getLocalData() (map[string]int64, error) {
|
||||
// get a list of all entries
|
||||
entries, _, err := synccommon.WalkEntryDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get a list of all entry modification times
|
||||
modList := synccommon.GetModTimes(entries)
|
||||
|
||||
// map the entries to their modification times
|
||||
entryModMap := make(map[string]int64)
|
||||
for i, entry := range entries {
|
||||
entryModMap[entry] = modList[i]
|
||||
}
|
||||
|
||||
// return the lists
|
||||
return entryModMap, nil
|
||||
}
|
||||
|
||||
// targetLocationFormatSFTP formats the target location to match the remote server's entry directory and path separator.
|
||||
func targetLocationFormatSFTP(targetName, serverEntryRoot string, serverIsWindows bool) string {
|
||||
// getRealPathSFTP formats the vanityPath to match the remote server's entry/age file directory and path separator.
|
||||
func getRealPathSFTP(vanityPath, serverEntryRoot string, serverIsWindows bool) string {
|
||||
if !serverIsWindows {
|
||||
return serverEntryRoot + targetName
|
||||
} else {
|
||||
return serverEntryRoot + strings.ReplaceAll(targetName, "/", "\\")
|
||||
return serverEntryRoot + vanityPath
|
||||
}
|
||||
return serverEntryRoot + strings.ReplaceAll(vanityPath, "/", "\\")
|
||||
}
|
||||
|
||||
// getRealPathSFTP formats the vanityPath to match the remote server's entry/age file directory and path separator.
|
||||
func getRealAgePathSFTP(vanityPath, serverAgeDir string, serverIsWindows bool) string {
|
||||
if !serverIsWindows {
|
||||
return serverAgeDir + "/" + strings.ReplaceAll(vanityPath, "/", global.FSPath)
|
||||
}
|
||||
return serverAgeDir + "\\" + strings.ReplaceAll(vanityPath, "/", global.FSPath)
|
||||
}
|
||||
|
||||
// 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) error {
|
||||
func sftpSync(sshClient *ssh.Client, sshEntryRoot, sshAgeDir string, sshIsWindows bool, syncListsV *syncListsT) error {
|
||||
// create an SFTP client from sshClient
|
||||
sftpClient, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
@@ -222,17 +166,16 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
|
||||
// iterate over the download list
|
||||
var filesTransferred bool
|
||||
for _, entryName := range downloadList {
|
||||
for _, vanityPath := range syncListsV.Download {
|
||||
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 " + synccommon.AnsiDownload + entryName + back.AnsiReset)
|
||||
fmt.Println("Downloading " + back.AnsiGreen + vanityPath + back.AnsiReset)
|
||||
|
||||
// store path to remote entry
|
||||
remoteEntryFullPath := targetLocationFormatSFTP(entryName, sshEntryRoot, sshIsWindows)
|
||||
remoteFileRealPath := getRealPathSFTP(vanityPath, sshEntryRoot, sshIsWindows)
|
||||
|
||||
// save modification time of remote file
|
||||
var fileInfo os.FileInfo
|
||||
fileInfo, err = sftpClient.Stat(remoteEntryFullPath)
|
||||
fileInfo, err = sftpClient.Stat(remoteFileRealPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to get remote file info (mod time): " + err.Error())
|
||||
}
|
||||
@@ -240,17 +183,17 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
|
||||
// open remote file
|
||||
var remoteFile *sftp.File
|
||||
remoteFile, err = sftpClient.Open(remoteEntryFullPath)
|
||||
remoteFile, err = sftpClient.Open(remoteFileRealPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to open remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// store path to local entry
|
||||
localEntryFullPath := global.TargetLocationFormat(entryName)
|
||||
// store path to local file
|
||||
localFileRealPath := global.GetRealPath(vanityPath)
|
||||
|
||||
// create local file
|
||||
var localFile *os.File
|
||||
localFile, err = os.OpenFile(localEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
localFile, err = os.OpenFile(localFileRealPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return errors.New("unable to create local file: " + err.Error())
|
||||
}
|
||||
@@ -258,6 +201,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
// download the file
|
||||
_, err = remoteFile.WriteTo(localFile)
|
||||
if err != nil {
|
||||
_ = localFile.Close()
|
||||
return errors.New("unable to download remote file: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -266,8 +210,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
_ = localFile.Close()
|
||||
|
||||
// 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 {
|
||||
if err = os.Chtimes(localFileRealPath, time.Now(), modTime); err != nil {
|
||||
return errors.New("unable to set local file modification time: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -278,17 +221,28 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
|
||||
// iterate over the upload list
|
||||
filesTransferred = false
|
||||
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)
|
||||
for _, vanityPath := range syncListsV.Upload {
|
||||
// determine if local file is an age file
|
||||
var isAgeFile bool
|
||||
if strings.HasPrefix(vanityPath, global.FSMisc) {
|
||||
vanityPath = strings.TrimLeft(vanityPath, global.FSMisc)
|
||||
isAgeFile = true
|
||||
} else {
|
||||
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 " + back.AnsiBlue + vanityPath + back.AnsiReset)
|
||||
}
|
||||
|
||||
fmt.Println("Uploading " + synccommon.AnsiUpload + entryName + back.AnsiReset)
|
||||
|
||||
// store path to local entry
|
||||
localEntryFullPath := global.TargetLocationFormat(entryName)
|
||||
// store path to local file
|
||||
var localFileRealPath string
|
||||
if isAgeFile {
|
||||
localFileRealPath = global.GetRealAgePath(vanityPath)
|
||||
} else {
|
||||
localFileRealPath = global.GetRealPath(vanityPath)
|
||||
}
|
||||
|
||||
// save modification time of local file
|
||||
var fileInfo os.FileInfo
|
||||
fileInfo, err = os.Stat(localEntryFullPath)
|
||||
fileInfo, err = os.Stat(localFileRealPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to get local file info (mod time): " + err.Error())
|
||||
}
|
||||
@@ -296,24 +250,32 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
|
||||
// open local file
|
||||
var localFile *os.File
|
||||
localFile, err = os.Open(localEntryFullPath)
|
||||
localFile, err = os.Open(localFileRealPath)
|
||||
if err != nil {
|
||||
return errors.New("unable to open local file: " + err.Error())
|
||||
}
|
||||
|
||||
// store path to remote entry
|
||||
remoteEntryFullPath := targetLocationFormatSFTP(entryName, sshEntryRoot, sshIsWindows)
|
||||
var remoteFileRealPath string
|
||||
if isAgeFile {
|
||||
remoteFileRealPath = getRealAgePathSFTP(vanityPath, sshAgeDir, sshIsWindows)
|
||||
} else {
|
||||
remoteFileRealPath = getRealPathSFTP(vanityPath, sshEntryRoot, sshIsWindows)
|
||||
}
|
||||
|
||||
// create remote file
|
||||
var remoteFile *sftp.File
|
||||
remoteFile, err = sftpClient.OpenFile(remoteEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
|
||||
remoteFile, err = sftpClient.OpenFile(remoteFileRealPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
|
||||
if err != nil {
|
||||
_ = localFile.Close()
|
||||
return errors.New("unable to create remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// upload the file
|
||||
_, err = localFile.WriteTo(remoteFile)
|
||||
if err != nil {
|
||||
_ = localFile.Close()
|
||||
_ = remoteFile.Close()
|
||||
return errors.New("unable to upload local file: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -322,14 +284,12 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
_ = remoteFile.Close()
|
||||
|
||||
// set permissions on remote file
|
||||
err = sftpClient.Chmod(remoteEntryFullPath, 0600)
|
||||
if err != nil {
|
||||
if err = sftpClient.Chmod(remoteFileRealPath, 0600); err != nil {
|
||||
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 {
|
||||
if err = sftpClient.Chtimes(remoteFileRealPath, time.Now(), modTime); err != nil {
|
||||
return errors.New("unable to set remote file modification time: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -341,157 +301,143 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
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, error) {
|
||||
// syncLists determines which entries need to be downloaded and uploaded
|
||||
// for synchronization and calls sftpSync with this information.
|
||||
func syncLists(sshClient *ssh.Client, sshEntryRoot, sshAgeDir string, sshIsWindows bool, timeSyncedErr error, localEntryMap, remoteEntryMap synccommon.EntryMapT) (*syncListsT, error) {
|
||||
// initialize slices to store entries that need to be downloaded or uploaded
|
||||
var downloadList, uploadList []string
|
||||
var syncListsV syncListsT
|
||||
|
||||
// iterate over client entries
|
||||
for entry, localModTime := range localEntryModMap {
|
||||
// check if the entry is present in the server map
|
||||
if remoteModTime, present := remoteEntryModMap[entry]; present {
|
||||
// iterate over client entries in local map
|
||||
for vanityPath, localInfo := range localEntryMap {
|
||||
// check if the entry is present on the server
|
||||
if _, exists := remoteEntryMap[vanityPath]; exists {
|
||||
// entry exists on both client and server, compare mod times
|
||||
if remoteModTime > localModTime {
|
||||
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(synccommon.AnsiUpload+entry+back.AnsiReset, "is newer on client, adding to upload list")
|
||||
uploadList = append(uploadList, entry)
|
||||
remoteInfo := remoteEntryMap[vanityPath]
|
||||
if remoteInfo.ModTime > localInfo.ModTime {
|
||||
fmt.Println(back.AnsiGreen+vanityPath+back.AnsiReset, "is newer on server, adding to download list")
|
||||
syncListsV.Download = append(syncListsV.Download, vanityPath)
|
||||
if remoteInfo.AgeTimestamp != nil {
|
||||
if err := age.Entry(vanityPath, *remoteInfo.AgeTimestamp); err != nil {
|
||||
return nil, errors.New("unable to update age timestamp for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
} else if remoteInfo.ModTime < localInfo.ModTime {
|
||||
fmt.Println(back.AnsiBlue+vanityPath+back.AnsiReset, "is newer on client, adding to upload list")
|
||||
syncListsV.Upload = append(syncListsV.Upload, vanityPath)
|
||||
if localInfo.AgeTimestamp != nil && localInfo.AgeTimestamp != remoteInfo.AgeTimestamp {
|
||||
syncListsV.Upload = append(syncListsV.Upload, global.FSMisc+vanityPath)
|
||||
}
|
||||
}
|
||||
// remove entry from remoteEntryModMap (process of elimination)
|
||||
delete(remoteEntryModMap, entry)
|
||||
// remove entry from remote map (process of elimination)
|
||||
delete(remoteEntryMap, vanityPath)
|
||||
} else {
|
||||
fmt.Println(synccommon.AnsiUpload+entry+back.AnsiReset, "does not exist on server, adding to upload list")
|
||||
uploadList = append(uploadList, entry)
|
||||
fmt.Println(back.AnsiBlue+vanityPath+back.AnsiReset, "does not exist on server, adding to upload list")
|
||||
syncListsV.Upload = append(syncListsV.Upload, vanityPath)
|
||||
if localInfo.AgeTimestamp != nil {
|
||||
syncListsV.Upload = append(syncListsV.Upload, global.FSMisc+vanityPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// iterate over remaining entries in remoteEntryModMap
|
||||
for entry := range remoteEntryModMap {
|
||||
fmt.Println(synccommon.AnsiDownload+entry+back.AnsiReset, "does not exist on client, adding to download list")
|
||||
downloadList = append(downloadList, entry)
|
||||
// iterate over remaining entries in remote map
|
||||
for vanityPath, remoteInfo := range remoteEntryMap {
|
||||
fmt.Println(back.AnsiGreen+vanityPath+back.AnsiReset, "does not exist on client, adding to download list")
|
||||
syncListsV.Download = append(syncListsV.Download, vanityPath)
|
||||
if err := os.MkdirAll(global.GetRealPath(remoteInfo.ContainingFolder), 0700); err != nil {
|
||||
return nil, errors.New("unable to create containing folder for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
if remoteInfo.AgeTimestamp != nil {
|
||||
if err := age.Entry(vanityPath, *remoteInfo.AgeTimestamp); err != nil {
|
||||
return nil, errors.New("unable to update age timestamp for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if timeSyncedErr == nil && (max(len(syncListsV.Download), len(syncListsV.Upload)) > 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
|
||||
err := sftpSync(sshClient, sshEntryRoot, sshIsWindows, downloadList, uploadList)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
|
||||
if err := sftpSync(sshClient, sshEntryRoot, sshAgeDir, sshIsWindows, &syncListsV); err != nil {
|
||||
return 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
|
||||
back.Exit(global.ErrorSyncProcess)
|
||||
fmt.Println("Client is synchronized with server")
|
||||
}
|
||||
|
||||
fmt.Println("Client is synchronized with server")
|
||||
|
||||
if returnLists {
|
||||
return [3][]string{nil, downloadList, uploadList}, nil
|
||||
}
|
||||
return [3][]string{nil, nil, nil}, nil
|
||||
return &syncListsV, timeSyncedErr
|
||||
}
|
||||
|
||||
// deletionSync removes entries from the client that have been deleted on the server (multi-client deletion).
|
||||
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(synccommon.AnsiDelete+deletion+back.AnsiReset, "has been sheared, removing locally (if it exists)")
|
||||
err := os.RemoveAll(global.TargetLocationFormat(deletion))
|
||||
if err != nil {
|
||||
return errors.New("unable to shear " + deletion + " locally: " + err.Error())
|
||||
func deletionSync(deletions []synccommon.Deletion) error {
|
||||
var entryDeleted bool
|
||||
for i := range deletions {
|
||||
if !deletions[i].IsAgeFile {
|
||||
entryDeleted = true // set a flag to indicate that at least one entry has been deleted (used to determine whether to print a gap between deletion and other messages)
|
||||
fmt.Println(synccommon.AnsiDelete+deletions[i].VanityPath+back.AnsiReset, "has been sheared, removing locally (if it exists)")
|
||||
}
|
||||
if err := os.RemoveAll(global.GetRealPath(deletions[i].VanityPath)); err != nil {
|
||||
if !deletions[i].IsAgeFile {
|
||||
return errors.New("unable to shear " + deletions[i].VanityPath + " locally: " + err.Error())
|
||||
}
|
||||
return errors.New("unable to shear age file for " + deletions[i].VanityPath + " locally: " + err.Error())
|
||||
}
|
||||
}
|
||||
if filesDeleted {
|
||||
if entryDeleted {
|
||||
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) error {
|
||||
for _, folder := range folders {
|
||||
// store the full local path of the folder
|
||||
folderFullPath := global.TargetLocationFormat(folder)
|
||||
|
||||
// check if target path already exists
|
||||
isAccessible, err := back.TargetIsFile(folderFullPath, false)
|
||||
|
||||
if !isAccessible {
|
||||
err := os.MkdirAll(folderFullPath, 0700)
|
||||
if err != nil {
|
||||
return errors.New("unable to create folder (" + folder + "): " + err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
return errors.New("unable to create folder (" + folder + "): " + err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunJob runs the SSH sync job.
|
||||
// Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client.
|
||||
func RunJob(returnLists bool) ([3][]string, error) {
|
||||
// RunJob runs the SSH sync job and returns deletions, downloads,
|
||||
// and uploads lists for the client to report to the user.
|
||||
func RunJob() (*syncListsT, error) {
|
||||
// get SSH client to re-use throughout the sync process
|
||||
sshClient, offlineMode, sshIsWindows, sshEntryRoot, err := GetSSHClient()
|
||||
sshClient, offlineMode, sshIsWindows, sshEntryRoot, sshAgeDir, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
return [3][]string{nil, nil, nil}, nil
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to connect to SSH client: " + err.Error())
|
||||
return nil, errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
defer func(sshClient *ssh.Client) {
|
||||
_ = sshClient.Close()
|
||||
}(sshClient)
|
||||
|
||||
// fetch remote lists
|
||||
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient)
|
||||
remoteEntryMap, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to fetch remote data: " + err.Error())
|
||||
return nil, errors.New("unable to fetch remote data: " + err.Error())
|
||||
}
|
||||
|
||||
// sync deletions
|
||||
err = deletionSync(deletions)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync deletions: " + err.Error())
|
||||
}
|
||||
|
||||
// sync folders
|
||||
err = folderSync(remoteFolders)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync folders: " + err.Error())
|
||||
if err = deletionSync(deletions); err != nil {
|
||||
return nil, errors.New("unable to sync deletions: " + err.Error())
|
||||
}
|
||||
|
||||
// fetch local lists
|
||||
localEntryModMap, err := getLocalData()
|
||||
localEntryMap, err := synccommon.GetAllEntryData()
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to fetch local entry data: " + err.Error())
|
||||
return nil, errors.New("unable to fetch local entry data: " + err.Error())
|
||||
}
|
||||
|
||||
// before syncing lists, ensure the client and server clocks are synced within 45 seconds
|
||||
var timeSynced = true
|
||||
var timeSyncedErr error
|
||||
timeDiff := serverTime - clientTime
|
||||
if timeDiff < -45 || timeDiff > 45 {
|
||||
timeSynced = false
|
||||
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)
|
||||
timeSyncedErr = errors.New("client and server clocks are out of sync\n\nplease ensure both clocks are correct before attempting to sync again\n\na dry sync has 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)" + back.AnsiReset)
|
||||
}
|
||||
|
||||
// sync new and updated entries
|
||||
var lists [3][]string
|
||||
if returnLists {
|
||||
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, nil
|
||||
}
|
||||
_, err = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap)
|
||||
// if time is not synced, the time sync error and upload/download lists will be returned here
|
||||
syncListsV, err := syncLists(sshClient, *sshEntryRoot, *sshAgeDir, *sshIsWindows, timeSyncedErr, localEntryMap, remoteEntryMap)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to sync entries: " + err.Error())
|
||||
return syncListsV, 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
|
||||
|
||||
// add deletions info to sync lists
|
||||
for i := range deletions {
|
||||
if !deletions[i].IsAgeFile {
|
||||
syncListsV.Delete = append(syncListsV.Delete, deletions[i].VanityPath)
|
||||
}
|
||||
}
|
||||
|
||||
return syncListsV, nil
|
||||
}
|
||||
|
||||
+127
-31
@@ -1,27 +1,35 @@
|
||||
package syncclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/go-boilerplate/security"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
"github.com/rwinkhart/libmutton/synccommon"
|
||||
)
|
||||
|
||||
// ShearRemoteFromClient removes the target file or directory from
|
||||
// ShearRemote 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
|
||||
func ShearRemote(vanityPath string, onlyShearAgeFile bool) error {
|
||||
deviceID, isDir, err := synccommon.ShearLocal(vanityPath, "", onlyShearAgeFile) // 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()
|
||||
var modifier string
|
||||
var output []byte
|
||||
sshClient, offlineMode, _, _, _, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
goto end
|
||||
}
|
||||
@@ -32,20 +40,25 @@ func ShearRemoteFromClient(targetLocationIncomplete string) error {
|
||||
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 += "/"
|
||||
// ensure vanityPath ends with a slash if it is a directory (for clarity in shear message)
|
||||
if isDir && !strings.HasSuffix(vanityPath, "/") {
|
||||
vanityPath += "/"
|
||||
}
|
||||
|
||||
// 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 onlyShearAgeFile {
|
||||
modifier = "-age"
|
||||
}
|
||||
output, err = GetSSHOutput(sshClient, "libmuttonserver shear"+modifier, deviceID+"\n"+strings.ReplaceAll(vanityPath, global.PathSeparator, global.FSPath))
|
||||
if err != nil {
|
||||
return errors.New("unable to shear target remotely: " + err.Error())
|
||||
}
|
||||
if len(output) > 11 {
|
||||
return errors.New("unable to complete shear; server-side error occurred: " + strings.ReplaceAll(string(output)[11:len(output)-2], global.FSSpace, "\n"))
|
||||
}
|
||||
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
if err = sshClient.Close(); err != nil {
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -54,14 +67,14 @@ end:
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameRemoteFromClient renames oldLocationIncomplete to newLocationIncomplete on
|
||||
// RenameRemote renames oldVanityPath to newVanityPath 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 {
|
||||
func RenameRemote(oldVanityPath, newVanityPath string) error {
|
||||
// move the target on the local system
|
||||
if err := synccommon.RenameLocal(oldVanityPath, newVanityPath); err != nil {
|
||||
return errors.New("unable to rename target locally: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -69,30 +82,34 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string)
|
||||
if err != nil {
|
||||
return errors.New("unable to generate device ID list: " + err.Error())
|
||||
}
|
||||
if deviceIDList[0].Name() == "" {
|
||||
return errors.New("unable to rename target remotely: no device ID found")
|
||||
}
|
||||
|
||||
// create an SSH client
|
||||
sshClient, offlineMode, _, _, err := GetSSHClient()
|
||||
var output []byte
|
||||
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",
|
||||
output, err = GetSSHOutput(sshClient, "libmuttonserver rename",
|
||||
(deviceIDList)[0].Name()+"\n"+
|
||||
strings.ReplaceAll(oldLocationIncomplete, global.PathSeparator, global.FSPath)+"\n"+
|
||||
strings.ReplaceAll(newLocationIncomplete, global.PathSeparator, global.FSPath))
|
||||
strings.ReplaceAll(oldVanityPath, global.PathSeparator, global.FSPath)+"\n"+
|
||||
strings.ReplaceAll(newVanityPath, global.PathSeparator, global.FSPath))
|
||||
if err != nil {
|
||||
return errors.New("unable to rename target remotely: " + err.Error())
|
||||
}
|
||||
if len(output) > 11 {
|
||||
return errors.New("unable to complete rename; server-side error occurred: " + strings.ReplaceAll(string(output)[11:len(output)-2], global.FSSpace, "\n"))
|
||||
}
|
||||
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
if err = sshClient.Close(); err != nil {
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -101,19 +118,20 @@ end:
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddFolderRemoteFromClient creates a new entry-containing directory
|
||||
// AddFolderRemote 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 {
|
||||
func AddFolderRemote(vanityPath string) error {
|
||||
// add the folder on the local system
|
||||
if err := synccommon.AddFolderLocal(vanityPath); err != nil {
|
||||
return errors.New("unable to add folder locally: " + err.Error())
|
||||
}
|
||||
|
||||
// create an SSH client
|
||||
sshClient, offlineMode, _, _, err := GetSSHClient()
|
||||
var output []byte
|
||||
sshClient, offlineMode, _, _, _, err := GetSSHClient()
|
||||
if offlineMode {
|
||||
goto end
|
||||
}
|
||||
@@ -122,14 +140,16 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string) 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
|
||||
output, err = GetSSHOutput(sshClient, "libmuttonserver addfolder", strings.ReplaceAll(vanityPath, 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())
|
||||
}
|
||||
if len(output) > 11 {
|
||||
return errors.New("unable to complete addfolder; server-side error occurred: " + strings.ReplaceAll(string(output)[11:len(output)-2], global.FSSpace, "\n"))
|
||||
}
|
||||
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
if err = sshClient.Close(); err != nil {
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -137,3 +157,79 @@ end:
|
||||
back.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenDeviceID 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, the remote AgeDir, and OS type indicator.
|
||||
func GenDeviceID(oldDeviceID *string, prefix string) (string, string, bool, error) {
|
||||
// generate new device ID
|
||||
if prefix == "" {
|
||||
prefix, _ = os.Hostname()
|
||||
}
|
||||
newDeviceID := prefix + "-" + string(security.BytesGen(rand.Intn(32)+48, 0.2, 1)) + "-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
// create new device ID file (locally)
|
||||
newDeviceIDPath := global.CfgDir + global.PathSeparator + "devices" + global.PathSeparator + newDeviceID
|
||||
var oldDeviceIDPath string
|
||||
if oldDeviceID != nil {
|
||||
oldDeviceIDPath = global.CfgDir + global.PathSeparator + "devices" + global.PathSeparator + *oldDeviceID
|
||||
}
|
||||
f, err := os.OpenFile(newDeviceIDPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return "", "", false, 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 != nil {
|
||||
// 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 := GetSSHClient()
|
||||
if err != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", false, errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
registerReqBytes, err := json.Marshal(synccommon.RegisterReqT{NewDeviceID: newDeviceID, OldDeviceID: oldDeviceID})
|
||||
if err != nil {
|
||||
return "", "", false, errors.New("unable to marshal client register request: " + err.Error())
|
||||
}
|
||||
output, err := GetSSHOutput(sshClient, "libmuttonserver register", string(registerReqBytes))
|
||||
if err != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", false, errors.New("unable to register device ID with server: " + err.Error())
|
||||
}
|
||||
var registerResp synccommon.RegisterRespT
|
||||
if err = json.Unmarshal(output, ®isterResp); err != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", false, errors.New("unable to unmarshal server register response: " + err.Error())
|
||||
}
|
||||
if registerResp.ErrMsg != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", false, errors.New("unable to complete register; server-side error occurred: " + strings.ReplaceAll(*registerResp.ErrMsg, global.FSSpace, "\n"))
|
||||
}
|
||||
_ = sshClient.Close() // ignore error; non-critical/unlikely/not much could be done about it
|
||||
|
||||
// remove old device ID file (locally; may not exist)
|
||||
if oldDeviceID != nil {
|
||||
if err = os.RemoveAll(oldDeviceIDPath); err != nil {
|
||||
cleanupOnFail()
|
||||
return "", "", false, errors.New("unable to remove old device ID file (locally): " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return registerResp.EntryRoot, registerResp.AgeDir, registerResp.IsWindows, nil
|
||||
}
|
||||
|
||||
+116
-40
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
@@ -12,22 +13,67 @@ import (
|
||||
|
||||
// ANSI color constants used only in this file
|
||||
const (
|
||||
AnsiDelete = "\033[38;5;1m"
|
||||
AnsiDownload = "\033[38;5;2m"
|
||||
AnsiUpload = "\033[38;5;4m"
|
||||
AnsiDelete = "\033[38;5;1m"
|
||||
)
|
||||
|
||||
var RootLength = len(global.EntryRoot) // length of global.EntryRoot string
|
||||
// FetchRespT defines the structure of responses from `libmuttonserver fetch`.
|
||||
type FetchRespT struct {
|
||||
ErrMsg *string `json:"errMsg"` // nil if no error occurred
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
Deletions []Deletion `json:"deletions"`
|
||||
Entries EntryMapT `json:"entries"`
|
||||
}
|
||||
type Deletion struct {
|
||||
VanityPath string `json:"vanityPath"`
|
||||
IsAgeFile bool `json:"isAgeFile"`
|
||||
}
|
||||
type EntryMapT map[string]EntryT // map vanity paths to containing folders and mod+age timestamps
|
||||
type EntryT struct {
|
||||
ContainingFolder string `json:"containingFolder"`
|
||||
ModTime int64 `json:"modTime"`
|
||||
AgeTimestamp *int64 `json:"ageTimestamp"` // nil if no age file is present (non-password entry)
|
||||
}
|
||||
|
||||
// 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())
|
||||
// RegisterRespT defines the structure of responses from `libmuttonserver register`
|
||||
type RegisterRespT struct {
|
||||
ErrMsg *string `json:"errMsg"` // nil if no error occurred
|
||||
EntryRoot string `json:"entryRoot"`
|
||||
AgeDir string `json:"ageDir"`
|
||||
IsWindows bool `json:"isWindows"`
|
||||
}
|
||||
|
||||
type RegisterReqT struct {
|
||||
NewDeviceID string `json:"newDeviceID"`
|
||||
OldDeviceID *string `json:"oldDeviceID"` // nil if not replacing an existing device ID
|
||||
}
|
||||
|
||||
// GetAllEntryData returns a map of all vanity paths to
|
||||
// their respective containing folders and mod+age timestamps.
|
||||
func GetAllEntryData() (EntryMapT, error) {
|
||||
var err error
|
||||
entryList, _, err := WalkEntryDir()
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to walk entry directory: " + err.Error())
|
||||
}
|
||||
|
||||
return modList
|
||||
// initialize vanityPath keys in map
|
||||
outputEntries := make(EntryMapT)
|
||||
var modInfo, ageInfo os.FileInfo
|
||||
for _, vanityPath := range entryList {
|
||||
containingFolder := vanityPath[:strings.LastIndex(vanityPath, "/")]
|
||||
modInfo, err = os.Stat(global.GetRealPath(vanityPath))
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to read mod time for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
ageInfo, err = os.Stat(global.GetRealAgePath(vanityPath))
|
||||
var ageTimestamp *int64
|
||||
if err == nil {
|
||||
ageTimestamp = new(ageInfo.ModTime().Unix())
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, errors.New("unable to read age time for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
outputEntries[vanityPath] = EntryT{ContainingFolder: containingFolder, ModTime: modInfo.ModTime().Unix(), AgeTimestamp: ageTimestamp}
|
||||
}
|
||||
return outputEntries, nil
|
||||
}
|
||||
|
||||
// ShearLocal removes the target file or directory from the local system.
|
||||
@@ -35,7 +81,7 @@ func GetModTimes(entryList []string) []int64 {
|
||||
// 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) {
|
||||
func ShearLocal(vanityPath, clientDeviceID string, onlyShearAgeFile bool) (string, bool, error) {
|
||||
// determine if running on a server
|
||||
var onServer bool
|
||||
if clientDeviceID != "" {
|
||||
@@ -47,26 +93,33 @@ func ShearLocal(targetLocationIncomplete, clientDeviceID string) (string, bool,
|
||||
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)
|
||||
// add the sheared vanityPath 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)
|
||||
for i := range deviceIDList {
|
||||
if deviceIDList[i].Name() != clientDeviceID {
|
||||
if !onlyShearAgeFile {
|
||||
f, err := os.OpenFile(global.CfgDir+global.PathSeparator+"deletions"+global.PathSeparator+deviceIDList[i].Name()+global.FSSpace+"entry"+global.FSSpace+strings.ReplaceAll(vanityPath, "/", global.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
|
||||
return "", false, err
|
||||
}
|
||||
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
}
|
||||
_ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
f, err := os.OpenFile(global.CfgDir+global.PathSeparator+"deletions"+global.PathSeparator+deviceIDList[i].Name()+global.FSSpace+"age"+global.FSSpace+strings.ReplaceAll(vanityPath, "/", global.FSPath), os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
// failure to add the target to the deletions list will exit the program and result in a client re-uploading the target (non-critical)
|
||||
return "", false, err
|
||||
}
|
||||
_ = f.Close() // error ignored; if the file could be created, it can probably be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove the target locally
|
||||
targetLocationComplete := global.TargetLocationFormat(targetLocationIncomplete)
|
||||
realPath := global.GetRealPath(vanityPath)
|
||||
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)
|
||||
isAccessible, err := back.TargetIsFile(realPath, true)
|
||||
if !isAccessible {
|
||||
return "", false, err
|
||||
}
|
||||
@@ -74,9 +127,13 @@ func ShearLocal(targetLocationIncomplete, clientDeviceID string) (string, bool,
|
||||
isFile = true
|
||||
}
|
||||
}
|
||||
err = os.RemoveAll(targetLocationComplete)
|
||||
if err != nil {
|
||||
return "", false, errors.New("unable to remove local target: " + err.Error())
|
||||
if !onlyShearAgeFile {
|
||||
if err = os.RemoveAll(realPath); err != nil {
|
||||
return "", false, errors.New("unable to remove local entry (" + vanityPath + "): " + err.Error())
|
||||
}
|
||||
}
|
||||
if err = ShearAgeFileLocal(vanityPath); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if !onServer && len(deviceIDList) > 0 { // return the device ID if running on the client and a device ID exists (online mode)
|
||||
@@ -87,39 +144,58 @@ func ShearLocal(targetLocationIncomplete, clientDeviceID string) (string, bool,
|
||||
// do not exit program, as this function is used as part of ShearRemoteFromClient
|
||||
}
|
||||
|
||||
// ShearAgeFileLocal removes the age file for a vanity path.
|
||||
// This function should only be used directly by the server binary.
|
||||
func ShearAgeFileLocal(vanityPath string) error {
|
||||
if err := os.RemoveAll(global.AgeDir + global.PathSeparator + strings.ReplaceAll(vanityPath, "/", global.FSPath)); err != nil {
|
||||
return errors.New("unable to remove age file for " + vanityPath + ": " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func RenameLocal(oldVanityPath, newVanityPath string) error {
|
||||
// get full paths for both locations
|
||||
oldLocation := global.TargetLocationFormat(oldLocationIncomplete)
|
||||
newLocation := global.TargetLocationFormat(newLocationIncomplete)
|
||||
oldRealPath := global.GetRealPath(oldVanityPath)
|
||||
oldRealAgePath := global.GetRealAgePath(oldVanityPath)
|
||||
newRealPath := global.GetRealPath(newVanityPath)
|
||||
newRealAgePath := global.GetRealAgePath(newVanityPath)
|
||||
|
||||
// ensure newLocation does not exist
|
||||
isAccessible, _ := back.TargetIsFile(newLocation, true) // error is ignored because dir/file status is irrelevant
|
||||
isAccessible, _ := back.TargetIsFile(newRealPath, true) // error is ignored because dir/file status is irrelevant
|
||||
if isAccessible {
|
||||
return errors.New("new target (" + newLocation + ") already exists")
|
||||
return errors.New("new target (" + newRealPath + ") already exists")
|
||||
}
|
||||
|
||||
// rename oldLocation to newLocation
|
||||
err := os.Rename(oldLocation, newLocation)
|
||||
if err != nil {
|
||||
if err := os.Rename(oldRealPath, newRealPath); err != nil {
|
||||
return errors.New("unable to rename: " + err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
// do the same for the age file (if one exists) - also back up timestamp first
|
||||
fileInfo, err := os.Stat(oldRealAgePath)
|
||||
if err == nil { // assume age file does not exist if os.Stat errors
|
||||
if err = os.Rename(oldRealAgePath, newRealAgePath); err != nil {
|
||||
return errors.New("unable to rename: " + err.Error())
|
||||
}
|
||||
if err = os.Chtimes(newRealAgePath, time.Now(), fileInfo.ModTime()); err != nil {
|
||||
return errors.New("unable to set timestamp on age file for " + newVanityPath + ": " + 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 {
|
||||
func AddFolderLocal(vanityPath string) error {
|
||||
// create the target locally
|
||||
targetLocationComplete := global.TargetLocationFormat(targetLocationIncomplete)
|
||||
err := os.Mkdir(targetLocationComplete, 0700)
|
||||
if err != nil {
|
||||
realPath := global.GetRealPath(vanityPath)
|
||||
if err := os.Mkdir(realPath, 0700); err != nil {
|
||||
if os.IsExist(err) {
|
||||
fmt.Println(AnsiUpload + "Directory already exists - libmutton will still ensure it exists on the server")
|
||||
fmt.Println(back.AnsiBlue + "Directory already exists - libmutton will still ensure it exists on the server")
|
||||
} else {
|
||||
return errors.New("unable to create directory: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package synccommon
|
||||
|
||||
import (
|
||||
@@ -7,39 +5,37 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/libmutton/global"
|
||||
)
|
||||
|
||||
// WalkEntryDir walks the entry directory and returns lists of all files and directories found (two separate lists).
|
||||
// WalkEntryDir walks the entry directory and returns lists of all files and
|
||||
// directories found (two separate lists) relative to the libmutton entry root.
|
||||
// Regardless of platform, all paths are stored with forward slashes (UNIX-style).
|
||||
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
|
||||
var fileList, dirList []string
|
||||
|
||||
// walk entry directory
|
||||
err := filepath.WalkDir(global.EntryRoot,
|
||||
func(fullPath string, entry fs.DirEntry, err error) error {
|
||||
func(realPath string, entry fs.DirEntry, err error) error {
|
||||
|
||||
// check for errors encountered while walking directory
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New("entry directory does not exist; initialize libmutton to create it")
|
||||
} else {
|
||||
return errors.New("an unexpected error occurred while generating the entry list: " + err.Error())
|
||||
}
|
||||
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:], "\\", "/")
|
||||
vanityPath := global.GetVanityPath(realPath)
|
||||
|
||||
// append the path to the appropriate slice
|
||||
if !entry.IsDir() {
|
||||
fileList = append(fileList, trimmedPath)
|
||||
fileList = append(fileList, vanityPath)
|
||||
} else {
|
||||
dirList = append(dirList, trimmedPath)
|
||||
dirList = append(dirList, vanityPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1,50 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package synccommon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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, 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
|
||||
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) {
|
||||
return errors.New("entry directory does not exist; initialize libmutton to create it")
|
||||
} else {
|
||||
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:]
|
||||
|
||||
// append the path to the appropriate slice
|
||||
if !entry.IsDir() {
|
||||
fileList = append(fileList, trimmedPath)
|
||||
} else {
|
||||
dirList = append(dirList, trimmedPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileList, dirList, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+39
-34
@@ -1,6 +1,7 @@
|
||||
package syncserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -11,46 +12,50 @@ import (
|
||||
)
|
||||
|
||||
// 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, _ := 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(global.FSSpace)
|
||||
for _, entry := range entryList {
|
||||
fmt.Print(global.FSMisc + entry)
|
||||
// collect info
|
||||
entryMap, err := synccommon.GetAllEntryData()
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
deletionsList, err := os.ReadDir(global.CfgDir + global.PathSeparator + "deletions")
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// modification time list
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, mod := range modList {
|
||||
fmt.Print(global.FSMisc)
|
||||
fmt.Print(mod)
|
||||
}
|
||||
|
||||
// directory/folder list
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, dir := range dirList {
|
||||
fmt.Print(global.FSMisc + dir)
|
||||
}
|
||||
|
||||
// deletions list
|
||||
fmt.Print(global.FSSpace)
|
||||
for _, deletion := range deletionsList {
|
||||
// print deletion if it is relevant to the current client device
|
||||
affectedIDTargetLocationIncomplete := strings.Split(deletion.Name(), global.FSSpace)
|
||||
if affectedIDTargetLocationIncomplete[0] == clientDeviceID {
|
||||
fmt.Print(global.FSMisc + strings.ReplaceAll(affectedIDTargetLocationIncomplete[1], global.FSPath, "/"))
|
||||
// form response
|
||||
var fetchResp synccommon.FetchRespT
|
||||
//// server time
|
||||
fetchResp.ServerTime = time.Now().Unix()
|
||||
//// deletions
|
||||
for i := range deletionsList {
|
||||
// perform deletion if it is relevant to the current client device
|
||||
affectedIDVanityPath := strings.Split(deletionsList[i].Name(), global.FSSpace)
|
||||
if affectedIDVanityPath[0] == clientDeviceID {
|
||||
var isAgeFile bool
|
||||
if affectedIDVanityPath[1] == "age" {
|
||||
isAgeFile = true
|
||||
}
|
||||
fetchResp.Deletions = append(fetchResp.Deletions, synccommon.Deletion{VanityPath: strings.ReplaceAll(affectedIDVanityPath[2], global.FSPath, "/"), IsAgeFile: isAgeFile})
|
||||
|
||||
// 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(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
|
||||
if err = os.RemoveAll(global.CfgDir + global.PathSeparator + "deletions" + global.PathSeparator + deletionsList[i].Name()); err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
//// entries
|
||||
fetchResp.Entries = entryMap
|
||||
|
||||
// marshal and print response to send to client
|
||||
fetchRespBytes, err := json.Marshal(fetchResp)
|
||||
if err != nil {
|
||||
fmt.Printf("{\"errMsg\":\"%s\"}", err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Print(string(fetchRespBytes))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
## 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.
|
||||
|
||||
These changes include, but may expand beyond the following:
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -6,6 +6,3 @@ The intended way for libmutton to interact with the system clipboard is for it t
|
||||
Clipboard managers save a history of what has been copied to the clipboard, which is already a big enough issue on its own for people who copy sensitive information to their clipboard. Some **clipboard managers simply will not allow the clipboard to be empty** and will replace its contents with the last copied item if you attempt to clear it. One such naughty clipboard manager is **KDE Klipper**, which comes **packaged into KDE Plasma** and is typically **enabled by default** on most distributions. Due to this behavior, **KDE Klipper breaks libmutton's clipboard clearing functionality** and should not be left enabled.
|
||||
|
||||
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 in working around this.
|
||||
+13
-27
@@ -3,48 +3,34 @@
|
||||
|
||||
libmutton was designed to be usable as a library for building other compatible password managers off of. [MUTN](https://github.com/rwinkhart/MUTN) is the official reference CLI password manager, however libmutton can be implemented in many other unique ways.
|
||||
|
||||
All functionality in the `core` and `sync` packages are designed to be used in other implementations.
|
||||
|
||||
If any functionality in these two packages proves to be difficult to implement in a third-party client, please open an issue so that it can be addressed.
|
||||
## Dependency Replacements (IMPORTANT)
|
||||
In libmutton's `go.mod`, you'll see dependency replacements for `golang.org/x/sys` and `github.com/Microsoft/go-winio`. Full support for Windows and FreeBSD requires these patched modules, so be sure to maintain these same replacements in your client's `go.mod`!
|
||||
|
||||
## Build Tags
|
||||
Custom build tags can (and sometimes must) be used to achieve desired results.
|
||||
|
||||
These are as follows:
|
||||
- `interactive`: If making an interactive interface (GUI/TUI/interactive CLI), you probably need to use this build tag. Without it, your entire program will exit after any given operation is completed. This behavior is only desired for non-interactive CLI implementations, such as MUTN. Currently, most errors will result in the program exiting **even with this build tag**. Specific types of errors (such as config parsing/SSH dialing errors) have been made exempt from this behavior.
|
||||
- `interactive`: If making an interactive interface (GUI/TUI/interactive CLI), you probably need to use this build tag. Without it, your entire program will exit after any given operation is completed. This behavior is only desired for non-interactive CLI implementations, such as MUTN.
|
||||
- `wsl`: Allows creating a Linux binary that can interact with the Windows clipboard (for WSL)
|
||||
- `termux`: Allows creating an Android binary that can interact with the Termux clipboard (for Android)
|
||||
|
||||
## Required Global Variable Manipulation
|
||||
- `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.
|
||||
- `crypt.RetryPassword`, true by default, determines whether the crypt package should verify user-typed passwords and re-prompt if needed. Turn this off to handle this uniquely in the client.
|
||||
|
||||
## 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()`.
|
||||
- `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 `clip.ClearArgument()`.
|
||||
- `startrcwd`: Should be accepted by all libmutton implementations making use of the RCW daemon to cache passwords. Please accept a `startrcwd` argument that calls `crypt.RCWDArgument()`.
|
||||
|
||||
## Mobile Clipboard Management
|
||||
In an effort to reduce dependencies not needed in most environments, libmutton no longer provides clipboard management for mobile platforms. This should be handled by your GUI toolkit/framework.
|
||||
|
||||
## Configuration
|
||||
libmutton-based password manager clients should all share the same INI configuration file.
|
||||
libmutton-based password manager clients should all share the same JSON configuration file.
|
||||
|
||||
On UNIX-like systems, this is located at `~/.config/libmutton/libmutton.ini`. On Windows, it is located at `~\AppData\Local\libmutton\config\libmutton.ini`.
|
||||
On UNIX-like systems, this is located at `~/.config/libmutton/libmuttoncfg.json`. On Windows, it is located at `~\AppData\Local\libmutton\config\libmuttoncfg.json`.
|
||||
|
||||
If creating a third-party client that requires extra configuration to be stored, please use the `ClientSpecific` map in the `config.CfgT` type (as used by `config.Write()`) to save your application-specific configuration.
|
||||
|
||||
### Base `libmutton.ini` Layout
|
||||
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]
|
||||
sshUser = <remote user>
|
||||
sshIP = <remote ip>
|
||||
sshPort = <remote ssh port>
|
||||
sshKey = <ssh private key identity file path>
|
||||
sshKeyProtected = <true/false>
|
||||
sshEntryRoot = <remote entry root>
|
||||
sshIsWindows = <true/false>
|
||||
```
|
||||
If creating a third-party client that requires extra configuration to be stored, please use the same file and create a new INI section for your application-specific configuration, e.g.:
|
||||
```
|
||||
[THIRD-PARTY-CLIENT-NAME]
|
||||
configKey = <value>
|
||||
```
|
||||
This ensures that a user can use multiple client applications with the same configuration while avoiding conflicts.
|
||||
|
||||
## Entry Format
|
||||
|
||||
+2
-6
@@ -1,11 +1,7 @@
|
||||
## 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 < 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.
|
||||
Current libmutton-based password managers use a custom, embedded cryptography agent ([RCW](https://github.com/rwinkhart/rcw)). GPG-encrypted entries (from `pass` or libmutton releases prior to v0.4.0) 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 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.
|
||||
`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 v0.3.X 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`. 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.
|
||||
|
||||
Reference in New Issue
Block a user